From 92d71600ff594476b84d595814aefe819310c1ec Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Wed, 7 Feb 2024 15:58:35 -0800 Subject: [PATCH 1/2] fix: preserve pending events when initialization doesn't complete (offline) --- .../AnalyticsReactNativeExample/.detoxrc.js | 93 ------- .../e2e/jest.config.js | 13 - .../e2e/main.e2e.js | 229 ------------------ .../e2e/matchers.js | 34 --- .../e2e/mockServer.js | 56 ----- .../jest.config.js | 3 - .../src/__tests__/methods/process.test.ts | 8 +- packages/core/src/analytics.ts | 10 +- .../storage/__tests__/sovranStorage.test.ts | 31 ++- packages/core/src/storage/sovranStorage.ts | 57 ++++- packages/core/src/storage/types.ts | 6 + .../core/src/test-helpers/mockSegmentStore.ts | 31 +++ packages/sovran/src/store.ts | 6 +- 13 files changed, 127 insertions(+), 450 deletions(-) delete mode 100644 examples/AnalyticsReactNativeExample/.detoxrc.js delete mode 100644 examples/AnalyticsReactNativeExample/e2e/jest.config.js delete mode 100644 examples/AnalyticsReactNativeExample/e2e/main.e2e.js delete mode 100644 examples/AnalyticsReactNativeExample/e2e/matchers.js delete mode 100644 examples/AnalyticsReactNativeExample/e2e/mockServer.js delete mode 100644 examples/AnalyticsReactNativeExample/jest.config.js diff --git a/examples/AnalyticsReactNativeExample/.detoxrc.js b/examples/AnalyticsReactNativeExample/.detoxrc.js deleted file mode 100644 index a93d376b5..000000000 --- a/examples/AnalyticsReactNativeExample/.detoxrc.js +++ /dev/null @@ -1,93 +0,0 @@ -/** @type {Detox.DetoxConfig} */ -module.exports = { - testRunner: { - args: { - '$0': 'jest', - config: 'e2e/jest.config.js' - }, - jest: { - setupTimeout: 120000 - } - }, - behavior: { - init: { - reinstallApp: true, - exposeGlobals: false - }, - launchApp: "auto", - cleanup: { - shutdownDevice: false - } - }, - apps: { - 'ios.debug': { - type: 'ios.app', - binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/AnalyticsReactNativeExample.app', - build: 'xcodebuild -workspace ios/AnalyticsReactNativeExample.xcworkspace -scheme AnalyticsReactNativeExample -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build' - }, - 'ios.release': { - type: 'ios.app', - binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/AnalyticsReactNativeExample.app', - build: 'xcodebuild -workspace ios/AnalyticsReactNativeExample.xcworkspace -scheme AnalyticsReactNativeExample -configuration Release -sdk iphonesimulator -derivedDataPath ios/build' - }, - 'android.debug': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', - build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug', - reversePorts: [ - 8081 - ] - }, - 'android.release': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', - build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release' - } - }, - devices: { - simulator: { - type: 'ios.simulator', - device: { - type: 'iPhone 14' - } - }, - attached: { - type: 'android.attached', - device: { - adbName: '.*' - } - }, - emulator: { - type: 'android.emulator', - device: { - avdName: 'Pixel_API_21_AOSP' - } - } - }, - configurations: { - 'ios.sim.debug': { - device: 'simulator', - app: 'ios.debug' - }, - 'ios.sim.release': { - device: 'simulator', - app: 'ios.release' - }, - 'android.att.debug': { - device: 'attached', - app: 'android.debug' - }, - 'android.att.release': { - device: 'attached', - app: 'android.release' - }, - 'android.emu.debug': { - device: 'emulator', - app: 'android.debug' - }, - 'android.emu.release': { - device: 'emulator', - app: 'android.release' - } - } -}; diff --git a/examples/AnalyticsReactNativeExample/e2e/jest.config.js b/examples/AnalyticsReactNativeExample/e2e/jest.config.js deleted file mode 100644 index 8340b08d5..000000000 --- a/examples/AnalyticsReactNativeExample/e2e/jest.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/** @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { - maxWorkers: 1, - testTimeout: 240000, - rootDir: '..', - testMatch: ['/e2e/**/*.e2e.js'], - verbose: true, - reporters: ['detox/runners/jest/reporter'], - globalSetup: 'detox/runners/jest/globalSetup', - globalTeardown: 'detox/runners/jest/globalTeardown', - testEnvironment: 'detox/runners/jest/testEnvironment', -}; - diff --git a/examples/AnalyticsReactNativeExample/e2e/main.e2e.js b/examples/AnalyticsReactNativeExample/e2e/main.e2e.js deleted file mode 100644 index 9069366fc..000000000 --- a/examples/AnalyticsReactNativeExample/e2e/main.e2e.js +++ /dev/null @@ -1,229 +0,0 @@ -const { element, by, device } = require('detox'); - -import { startServer, stopServer } from './mockServer'; -import { setupMatchers } from './matchers'; -import { retry } from 'ts-retry-promise' - -const launchApp = async ( - launchArgs = { - newInstance: true, - launchArgs: { - detoxPrintBusyIdleResources: 'YES', - }, - } -) => { - await retry( - async () => { - try { - await device.launchApp(launchArgs) - } catch (error) { - error.message = `Failed to launch app with error: ${error.message}` - throw error - } - }, - { retries: 5, delay: 10 * 1000, timeout: 30 * 10000 } - ) -} - -const reloadReactNative = async () => { - await retry( - async () => { - try { - await device.reloadReactNative() - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to reload react native with error', error) - await launchApp() - } - }, - { retries: 5, delay: 10 * 1000, timeout: 30 * 10000 } - ) -} - -describe('#mainTest', () => { - const mockServerListener = jest.fn(); - - const trackButton = element(by.id('BUTTON_TRACK')); - const screenButton = element(by.id('BUTTON_SCREEN')); - const identifyButton = element(by.id('BUTTON_IDENTIFY')); - const groupButton = element(by.id('BUTTON_GROUP')); - const aliasButton = element(by.id('BUTTON_ALIAS')); - const resetButton = element(by.id('BUTTON_RESET')); - const flushButton = element(by.id('BUTTON_FLUSH')); - - beforeAll(async () => { - await startServer(mockServerListener); - await launchApp(); - setupMatchers(); - }); - - const clearLifecycleEvents = async () => { - await flushButton.tap(); - - mockServerListener.mockClear(); - expect(mockServerListener).not.toHaveBeenCalled(); - }; - - beforeEach(async () => { - mockServerListener.mockReset(); - await reloadReactNative(); - }); - - afterAll(async () => { - await stopServer(); - }); - - it('checks that lifecycle methods are triggered', async () => { - await flushButton.tap(); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveEventWith({ - type: 'track', - event: 'Application Opened', - }); - expect(events).toHaveEventWith({ - type: 'track', - event: 'Application Installed', - }); - }); - - it('checks that track & screen methods are logged', async () => { - await clearLifecycleEvents(); - - await trackButton.tap(); - await screenButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveLength(2); - expect(events).toHaveEventWith({ type: 'track', event: 'Track pressed' }); - expect(events).toHaveEventWith({ type: 'screen', name: 'Home Screen' }); - }); - - it('checks the identify method', async () => { - await clearLifecycleEvents(); - - await identifyButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveLength(1); - expect(events).toHaveEventWith({ - type: 'identify', - userId: 'user_2', - }); - }); - - it('checks the group method', async () => { - await clearLifecycleEvents(); - - await groupButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveLength(1); - expect(events).toHaveEventWith({ type: 'group', groupId: 'best-group' }); - }); - - it('checks the alias method', async () => { - await clearLifecycleEvents(); - - await aliasButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveLength(1); - expect(events).toHaveEventWith({ type: 'alias', userId: 'new-id' }); - }); - - it('reset the client and checks the user id', async () => { - await clearLifecycleEvents(); - - await identifyButton.tap(); - await trackButton.tap(); - await resetButton.tap(); - await screenButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - const screenEvent = events.find((item) => item.type === 'screen'); - - expect(events).toHaveLength(3); - expect(events).toHaveEventWith({ type: 'identify', userId: 'user_2' }); - expect(events).toHaveEventWith({ type: 'track', userId: 'user_2' }); - expect(screenEvent.userId).toBeUndefined(); - }); - - it('checks that the context is set properly', async () => { - await clearLifecycleEvents(); - - await trackButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const request = mockServerListener.mock.calls[0][0]; - const context = request.batch[0].context; - - expect(request.batch).toHaveLength(1); - expect(context.app.name).toBe('AnalyticsReactNativeExample'); - expect(context.app.version).toBe('1.0'); - expect(context.library.name).toBe('@segment/analytics-react-native'); - expect(context.locale).toBe('en-US'); - // This test only works in iOS for now - if (device.getPlatform() === 'ios') { - expect(context.network.wifi).toBe(true); - } - }); - - it('checks that persistence is working', async () => { - await clearLifecycleEvents(); - - await trackButton.tap(); - await identifyButton.tap(); - - await device.sendToHome(); - await device.launchApp({ newInstance: true }); - - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - const platform = device.getPlatform(); - - expect(events).toHaveLength(platform === 'android' ? 4 : 3); // Track + Identify + App Launch (+ Backgrounded on Android) - expect(events).toHaveEventWith({ type: 'identify', userId: 'user_2' }); - expect(events).toHaveEventWith({ type: 'track', userId: 'user_2' }); - expect(events).toHaveEventWith({ - type: 'track', - event: 'Application Opened', - }); - // Android only - // RN in Android immediately halts JS execution when leaving the app and sends the BG event (in iOS it happens after a short while when the OS decides to) - // Hence in Android the event list will contain this extra event - if (platform === 'android') { - expect(events).toHaveEventWith({ - type: 'track', - event: 'Application Backgrounded', - }); - } - }); -}); diff --git a/examples/AnalyticsReactNativeExample/e2e/matchers.js b/examples/AnalyticsReactNativeExample/e2e/matchers.js deleted file mode 100644 index a922ff138..000000000 --- a/examples/AnalyticsReactNativeExample/e2e/matchers.js +++ /dev/null @@ -1,34 +0,0 @@ -/* globals expect */ - -export const setupMatchers = () => { - expect.extend({ - toHaveEvent(events, eventType) { - return { - message: () => `Expect events to contain a ${eventType} event`, - pass: events.some((item) => item.type === eventType), - }; - }, - - toHaveEventWith(events, eventAtts) { - const hasEvent = events.some((item) => { - let isValid = true; - for (const [key, value] of Object.entries(eventAtts)) { - if (!(key in item)) { - isValid = false; - } else if (key in item && item[key] !== value) { - isValid = false; - } - } - return isValid; - }); - - return { - message: () => - `Expect events to contain an object with attributes: ${JSON.stringify( - eventAtts - )}`, - pass: hasEvent, - }; - }, - }); -}; diff --git a/examples/AnalyticsReactNativeExample/e2e/mockServer.js b/examples/AnalyticsReactNativeExample/e2e/mockServer.js deleted file mode 100644 index c38d644bc..000000000 --- a/examples/AnalyticsReactNativeExample/e2e/mockServer.js +++ /dev/null @@ -1,56 +0,0 @@ -const express = require('express'); -const bodyParser = require('body-parser'); - -const port = 9091; - -let server; - -export const startServer = async (mockServerListener) => { - if (server) { - throw new Error('Server is already running'); - } - - return new Promise((resolve) => { - const app = express(); - - app.use(bodyParser.json()); - - // Handles batch events - app.post('/events', (req, res) => { - console.log(`➡️ Received request`); - const body = req.body; - mockServerListener(body); - - res.status(200).send({ mockSuccess: true }); - }); - - // Handles settings calls - app.get('/settings/:writeKey/*', (req, res) => { - console.log(`➡️ Replying with Settings`); - res.status(200).send({ - integrations: { - 'Segment.io': {}, - }, - }); - }); - - server = app.listen(port, () => { - console.log(`🚀 Started mock server on port ${port}`); - resolve(); - }); - }); -}; - -export const stopServer = async () => { - return new Promise((resolve, reject) => { - if (server) { - server.close(() => { - console.log('✋ Mock server has stopped'); - server = undefined; - resolve(); - }); - } else { - reject('⚠️ Mock server is not running'); - } - }); -}; diff --git a/examples/AnalyticsReactNativeExample/jest.config.js b/examples/AnalyticsReactNativeExample/jest.config.js deleted file mode 100644 index 8eb675e9b..000000000 --- a/examples/AnalyticsReactNativeExample/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - preset: 'react-native', -}; diff --git a/packages/core/src/__tests__/methods/process.test.ts b/packages/core/src/__tests__/methods/process.test.ts index ba99ca2e2..bebece19e 100644 --- a/packages/core/src/__tests__/methods/process.test.ts +++ b/packages/core/src/__tests__/methods/process.test.ts @@ -35,7 +35,7 @@ describe('process', () => { jest.clearAllMocks(); }); - it('stamps basic data: timestamp and messageId for events when not ready', async () => { + it('stamps basic data: timestamp and messageId for pending events when not ready', async () => { const client = new SegmentClient(clientArgs); jest.spyOn(client.isReady, 'value', 'get').mockReturnValue(false); // @ts-ignore @@ -54,7 +54,7 @@ describe('process', () => { // While not ready only timestamp and messageId should be defined // @ts-ignore - const pendingEvents = client.pendingEvents; + const pendingEvents = client.store.pendingEvents.get(); expect(pendingEvents.length).toBe(1); const pendingEvent = pendingEvents[0]; expect(pendingEvent).toMatchObject(expectedEvent); @@ -76,7 +76,7 @@ describe('process', () => { }; // @ts-ignore - expect(client.pendingEvents.length).toBe(0); + expect(client.store.pendingEvents.get().length).toBe(0); expect(timeline.process).toHaveBeenCalledWith( expect.objectContaining(expectedEvent) @@ -105,7 +105,7 @@ describe('process', () => { } as SegmentEvent; // @ts-ignore - const pendingEvents = client.pendingEvents; + const pendingEvents = client.store.pendingEvents.get(); expect(pendingEvents.length).toBe(0); expect(timeline.process).toHaveBeenCalledWith( diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index 3355e4193..d7f3c77aa 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -86,8 +86,6 @@ export class SegmentClient { private timeline: Timeline; - private pendingEvents: SegmentEvent[] = []; - private pluginsToAdd: Plugin[] = []; private flushPolicyExecuter!: FlushPolicyExecuter; @@ -431,7 +429,7 @@ export class SegmentClient { if (this.isReady.value) { return this.startTimelineProcessing(event); } else { - this.pendingEvents.push(event); + this.store.pendingEvents.add(event); return event; } } @@ -497,10 +495,12 @@ export class SegmentClient { } // Send all events in the queue - for (const e of this.pendingEvents) { + const pending = await this.store.pendingEvents.get(true); + for (const e of pending) { await this.startTimelineProcessing(e); + await this.store.pendingEvents.remove(e); } - this.pendingEvents = []; + // this.store.pendingEvents.set([]); } async flush(): Promise { diff --git a/packages/core/src/storage/__tests__/sovranStorage.test.ts b/packages/core/src/storage/__tests__/sovranStorage.test.ts index d49bba29f..babbd1fd0 100644 --- a/packages/core/src/storage/__tests__/sovranStorage.test.ts +++ b/packages/core/src/storage/__tests__/sovranStorage.test.ts @@ -4,15 +4,15 @@ import { createCallbackManager as mockCreateCallbackManager } from '../../test-h import { SovranStorage } from '../sovranStorage'; import type { Persistor } from '@segment/sovran-react-native'; -import type { Context, DeepPartial } from '../../types'; +import { EventType, type Context, type DeepPartial, SegmentEvent } from '../../types'; jest.mock('@segment/sovran-react-native', () => ({ registerBridgeStore: jest.fn(), createStore: (initialState: T) => { const callbackManager = mockCreateCallbackManager(); - let store = { - ...initialState, - }; + let store: T = Array.isArray(initialState) ? + [...initialState] as T: + {...initialState} as T; return { subscribe: jest @@ -30,7 +30,9 @@ jest.mock('@segment/sovran-react-native', () => ({ return store; } ), - getState: jest.fn().mockImplementation(() => ({ ...store })), + getState: jest.fn().mockImplementation(() => { + return Array.isArray(store) ? [...store] : { ...store } + }), }; }, })); @@ -125,4 +127,23 @@ describe('sovranStorage', () => { }); await commonAssertions(sovran); }); + + it('adds/removes pending events', async () => { + const sovran = new SovranStorage({ storeId: 'test' }); + console.log(sovran.pendingEvents.get()); + + // expect(sovran.pendingEvents.get().length).toBe(0); + + let event: SegmentEvent = { + messageId: '1', + type: EventType.TrackEvent, + event: "Track" + }; + await sovran.pendingEvents.add(event); + + expect(sovran.pendingEvents.get().length).toBe(1); + + await sovran.pendingEvents.remove(event); + expect(sovran.pendingEvents.get().length).toBe(0); + }) }); diff --git a/packages/core/src/storage/sovranStorage.ts b/packages/core/src/storage/sovranStorage.ts index 032f75138..15472a41f 100644 --- a/packages/core/src/storage/sovranStorage.ts +++ b/packages/core/src/storage/sovranStorage.ts @@ -28,21 +28,19 @@ import type { Settable, Dictionary, ReadinessStore, + Queue, } from './types'; type Data = { - events: SegmentEvent[]; - eventsToRetry: SegmentEvent[]; context: DeepPartial; settings: SegmentAPIIntegrations; consentSettings: SegmentAPIConsentSettings | undefined; userInfo: UserInfoState; filters: DestinationFilters; + pendingEvents: SegmentEvent[]; }; const INITIAL_VALUES: Data = { - events: [], - eventsToRetry: [], context: {}, settings: {}, consentSettings: undefined, @@ -52,6 +50,7 @@ const INITIAL_VALUES: Data = { userId: undefined, traits: undefined, }, + pendingEvents: [] }; const isEverythingReady = (state: ReadinessStore) => @@ -115,7 +114,7 @@ const addAnonymousId = }; function createStoreGetter< - U extends Record, + U extends object, Z extends keyof U | undefined = undefined, V = undefined, >(store: Store, key?: Z): getStateFunc { @@ -151,6 +150,8 @@ export class SovranStorage implements Storage { private userInfoStore: Store<{ userInfo: UserInfoState }>; private deepLinkStore: Store = deepLinkStore; private filtersStore: Store; + private pendingStore: Store; + readonly isReady: Watchable; @@ -172,6 +173,10 @@ export class SovranStorage implements Storage { readonly deepLinkData: Watchable; + readonly pendingEvents: Watchable & + Settable & + Queue; + constructor(config: StorageConfig) { this.storeId = config.storeId; this.storePersistor = config.storePersistor; @@ -181,6 +186,7 @@ export class SovranStorage implements Storage { hasRestoredSettings: false, hasRestoredUserInfo: false, hasRestoredFilters: false, + hasRestoredPendingEvents: false }); const markAsReadyGenerator = (key: keyof ReadinessStore) => () => { @@ -385,6 +391,47 @@ export class SovranStorage implements Storage { }, }; + // Pending Events + this.pendingStore = createStore( + INITIAL_VALUES.pendingEvents, + { + persist: { + storeId: `${this.storeId}-pendingEvents`, + persistor: this.storePersistor, + saveDelay: this.storePersistorSaveDelay, + onInitialized: markAsReadyGenerator('hasRestoredPendingEvents') + } + } + ) + + this.pendingEvents = { + get: createStoreGetter(this.pendingStore), + onChange: (callback: (value: SegmentEvent[]) => void) => + this.pendingStore.subscribe((store) => callback(store)), + set: async (value) => { + return await this.pendingStore.dispatch((state) => { + let newState: SegmentEvent[]; + if (value instanceof Function) { + newState = value(state); + } else { + newState = [...value]; + } + return newState + }) + }, + add: (event: SegmentEvent) => { + return this.pendingStore.dispatch((events) => ([ + ...events, + event + ])) + }, + remove: (event: SegmentEvent) => { + return this.pendingStore.dispatch((events) => + events.filter((e) => e.messageId != event.messageId) + ) + } + } + registerBridgeStore({ store: this.userInfoStore, actions: { diff --git a/packages/core/src/storage/types.ts b/packages/core/src/storage/types.ts index a1baee174..cc7edbfe2 100644 --- a/packages/core/src/storage/types.ts +++ b/packages/core/src/storage/types.ts @@ -7,6 +7,7 @@ import type { IntegrationSettings, RoutingRule, SegmentAPIIntegrations, + SegmentEvent, UserInfoState, } from '../types'; @@ -57,6 +58,7 @@ export interface ReadinessStore { hasRestoredSettings: boolean; hasRestoredUserInfo: boolean; hasRestoredFilters: boolean; + hasRestoredPendingEvents: boolean; } /** @@ -82,6 +84,10 @@ export interface Storage { readonly userInfo: Watchable & Settable; readonly deepLinkData: Watchable; + + readonly pendingEvents: Watchable & + Settable & + Queue; } export type DeepLinkData = { referring_application: string; diff --git a/packages/core/src/test-helpers/mockSegmentStore.ts b/packages/core/src/test-helpers/mockSegmentStore.ts index 4504d42fc..84e78bd21 100644 --- a/packages/core/src/test-helpers/mockSegmentStore.ts +++ b/packages/core/src/test-helpers/mockSegmentStore.ts @@ -2,6 +2,7 @@ import { SEGMENT_DESTINATION_KEY } from '../plugins/SegmentDestination'; import type { DeepLinkData, Dictionary, + Queue, Settable, Storage, Watchable, @@ -14,6 +15,7 @@ import type { RoutingRule, SegmentAPIConsentSettings, SegmentAPIIntegrations, + SegmentEvent, UserInfoState, } from '../types'; import { createCallbackManager } from './utils'; @@ -27,6 +29,7 @@ export type StoreData = { filters: DestinationFilters; userInfo: UserInfoState; deepLinkData: DeepLinkData; + pendingEvents: SegmentEvent[]; }; const INITIAL_VALUES: StoreData = { @@ -46,6 +49,7 @@ const INITIAL_VALUES: StoreData = { referring_application: '', url: '', }, + pendingEvents: [] }; export function createMockStoreGetter(fn: () => T) { @@ -80,6 +84,7 @@ export class MockSegmentStore implements Storage { filters: createCallbackManager(), userInfo: createCallbackManager(), deepLinkData: createCallbackManager(), + pendingEvents: createCallbackManager(), }; readonly isReady = { @@ -188,4 +193,30 @@ export class MockSegmentStore implements Storage { onChange: (callback: (value: DeepLinkData) => void) => this.callbacks.deepLinkData.register(callback), }; + + readonly pendingEvents: Watchable & Settable & Queue = { + get: createMockStoreGetter(() => { + return this.data.pendingEvents + }), + set: (value) => { + this.data.pendingEvents = + value instanceof Function + ? value(this.data.pendingEvents ?? []) + : [ ...value ]; + this.callbacks.pendingEvents.run(this.data.pendingEvents) + return this.data.pendingEvents + }, + add: (value: SegmentEvent) => { + this.data.pendingEvents.push(value); + this.callbacks.pendingEvents.run(this.data.pendingEvents); + return Promise.resolve(this.data.pendingEvents) + }, + remove: (value: SegmentEvent) => { + this.data.pendingEvents = this.data.pendingEvents.filter((e) => e.messageId != value.messageId) + this.callbacks.pendingEvents.run(this.data.pendingEvents); + return Promise.resolve(this.data.pendingEvents) + }, + onChange: (callback: (value: SegmentEvent[]) => void) => + this.callbacks.pendingEvents.register(callback), + } } diff --git a/packages/sovran/src/store.ts b/packages/sovran/src/store.ts index 53399bc31..4ac5d4723 100644 --- a/packages/sovran/src/store.ts +++ b/packages/sovran/src/store.ts @@ -102,7 +102,7 @@ export const createStore = ( initialState: T, config?: StoreConfig ): Store => { - let state = initialState; + let state: T = Array.isArray(initialState) ? [...initialState] as T : {...initialState} as T; const queue: { call: Action; finally?: (newState: T) => void }[] = []; const isPersisted = config?.persist !== undefined; let saveTimeout: ReturnType | undefined; @@ -163,7 +163,7 @@ export const createStore = ( function getState(safe: true): Promise; function getState(safe?: boolean): T | Promise { if (safe !== true) { - return { ...state }; + return Array.isArray(state) ? [...state] as T: { ...state }; } return new Promise((resolve) => { queue.push({ @@ -192,7 +192,7 @@ export const createStore = ( const action = queue.shift(); try { if (action !== undefined) { - const newState = await action.call(state); + const newState = await action.call(state as T); if (newState !== state) { state = newState; // TODO: Debounce notifications From b4f913227d3b70aa30877f92118ec2558fa2072d Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Wed, 7 Feb 2024 17:13:05 -0800 Subject: [PATCH 2/2] chore: auto fix linter issues --- packages/core/src/__tests__/api.test.ts | 17 +++++----- packages/core/src/analytics.ts | 7 +++- packages/core/src/errors.ts | 4 +-- .../flushPolicies/flush-policy-executer.ts | 4 ++- .../storage/__tests__/sovranStorage.test.ts | 21 +++++++----- packages/core/src/storage/sovranStorage.ts | 34 ++++++++----------- packages/core/src/storage/types.ts | 4 +-- .../core/src/test-helpers/mockSegmentStore.ts | 26 ++++++++------ packages/core/src/util.ts | 7 ++-- .../src/methods/parameterMapping.ts | 2 +- .../plugins/plugin-branch/src/methods/util.ts | 8 +++-- .../src/DeviceTokenPlugin.tsx | 12 ++----- .../plugin-firebase/src/FirebasePlugin.tsx | 3 +- .../plugins/plugin-idfa/src/IdfaPlugin.tsx | 6 ++-- packages/shared/src/setup.ts | 1 - packages/sovran/src/index.tsx | 2 +- .../src/persistor/async-storage-persistor.ts | 2 +- packages/sovran/src/store.ts | 6 ++-- 18 files changed, 88 insertions(+), 78 deletions(-) diff --git a/packages/core/src/__tests__/api.test.ts b/packages/core/src/__tests__/api.test.ts index 6e70046a1..86a378752 100644 --- a/packages/core/src/__tests__/api.test.ts +++ b/packages/core/src/__tests__/api.test.ts @@ -10,14 +10,15 @@ import * as context from '../context'; describe('#sendEvents', () => { beforeEach(() => { - jest.spyOn(context, 'getContext').mockImplementationOnce( - // eslint-disable-next-line @typescript-eslint/require-await - async (userTraits?: UserTraits): Promise => { - return { - traits: userTraits ?? {}, - } as Context; - } - ); + jest + .spyOn(context, 'getContext') + .mockImplementationOnce( + async (userTraits?: UserTraits): Promise => { + return { + traits: userTraits ?? {}, + } as Context; + } + ); jest .spyOn(Date.prototype, 'toISOString') diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index d7f3c77aa..4a4270a9c 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -318,7 +318,7 @@ export class SegmentClient { const filters = this.generateFiltersMap( resJson.middlewareSettings?.routingRules ?? [] ); - this.logger.info(`Received settings from Segment succesfully.`); + this.logger.info('Received settings from Segment succesfully.'); await Promise.all([ this.store.settings.set(integrations), this.store.consentSettings.set(consentSettings), @@ -426,6 +426,8 @@ export class SegmentClient { async process(incomingEvent: SegmentEvent) { const event = this.applyRawEventData(incomingEvent); + console.log(`Process: ${this.isReady.value}`); + if (this.isReady.value) { return this.startTimelineProcessing(event); } else { @@ -477,6 +479,9 @@ export class SegmentClient { * @param isReady */ private async onReady() { + console.log( + `onReady, pendingEvents=${this.store.pendingEvents.get().length}` + ); // Add all plugins awaiting store if (this.pluginsToAdd.length > 0 && !this.isAddingPlugins) { this.isAddingPlugins = true; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 41baf2610..5e98b7a88 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -116,8 +116,8 @@ export const translateHTTPError = (error: unknown): SegmentError => { error instanceof Error ? error.message : typeof error === 'string' - ? error - : 'Unknown error'; + ? error + : 'Unknown error'; return new NetworkError(-1, message, error); } }; diff --git a/packages/core/src/flushPolicies/flush-policy-executer.ts b/packages/core/src/flushPolicies/flush-policy-executer.ts index 92319c74d..b84dd43d8 100644 --- a/packages/core/src/flushPolicies/flush-policy-executer.ts +++ b/packages/core/src/flushPolicies/flush-policy-executer.ts @@ -29,7 +29,9 @@ export class FlushPolicyExecuter { } removeIndex(index: number): boolean { - if (index < 0) return false; + if (index < 0) { + return false; + } const policy = this.policies[index]; diff --git a/packages/core/src/storage/__tests__/sovranStorage.test.ts b/packages/core/src/storage/__tests__/sovranStorage.test.ts index babbd1fd0..192aaf7a2 100644 --- a/packages/core/src/storage/__tests__/sovranStorage.test.ts +++ b/packages/core/src/storage/__tests__/sovranStorage.test.ts @@ -4,15 +4,20 @@ import { createCallbackManager as mockCreateCallbackManager } from '../../test-h import { SovranStorage } from '../sovranStorage'; import type { Persistor } from '@segment/sovran-react-native'; -import { EventType, type Context, type DeepPartial, SegmentEvent } from '../../types'; +import { + EventType, + type Context, + type DeepPartial, + SegmentEvent, +} from '../../types'; jest.mock('@segment/sovran-react-native', () => ({ registerBridgeStore: jest.fn(), createStore: (initialState: T) => { const callbackManager = mockCreateCallbackManager(); - let store: T = Array.isArray(initialState) ? - [...initialState] as T: - {...initialState} as T; + let store: T = Array.isArray(initialState) + ? ([...initialState] as T) + : ({ ...initialState } as T); return { subscribe: jest @@ -31,7 +36,7 @@ jest.mock('@segment/sovran-react-native', () => ({ } ), getState: jest.fn().mockImplementation(() => { - return Array.isArray(store) ? [...store] : { ...store } + return Array.isArray(store) ? [...store] : { ...store }; }), }; }, @@ -134,10 +139,10 @@ describe('sovranStorage', () => { // expect(sovran.pendingEvents.get().length).toBe(0); - let event: SegmentEvent = { + const event: SegmentEvent = { messageId: '1', type: EventType.TrackEvent, - event: "Track" + event: 'Track', }; await sovran.pendingEvents.add(event); @@ -145,5 +150,5 @@ describe('sovranStorage', () => { await sovran.pendingEvents.remove(event); expect(sovran.pendingEvents.get().length).toBe(0); - }) + }); }); diff --git a/packages/core/src/storage/sovranStorage.ts b/packages/core/src/storage/sovranStorage.ts index 15472a41f..1fdef1616 100644 --- a/packages/core/src/storage/sovranStorage.ts +++ b/packages/core/src/storage/sovranStorage.ts @@ -50,7 +50,7 @@ const INITIAL_VALUES: Data = { userId: undefined, traits: undefined, }, - pendingEvents: [] + pendingEvents: [], }; const isEverythingReady = (state: ReadinessStore) => @@ -116,7 +116,7 @@ const addAnonymousId = function createStoreGetter< U extends object, Z extends keyof U | undefined = undefined, - V = undefined, + V = undefined >(store: Store, key?: Z): getStateFunc { type X = Z extends keyof U ? V : U; return createGetter( @@ -151,7 +151,6 @@ export class SovranStorage implements Storage { private deepLinkStore: Store = deepLinkStore; private filtersStore: Store; private pendingStore: Store; - readonly isReady: Watchable; @@ -173,8 +172,8 @@ export class SovranStorage implements Storage { readonly deepLinkData: Watchable; - readonly pendingEvents: Watchable & - Settable & + readonly pendingEvents: Watchable & + Settable & Queue; constructor(config: StorageConfig) { @@ -186,7 +185,7 @@ export class SovranStorage implements Storage { hasRestoredSettings: false, hasRestoredUserInfo: false, hasRestoredFilters: false, - hasRestoredPendingEvents: false + hasRestoredPendingEvents: false, }); const markAsReadyGenerator = (key: keyof ReadinessStore) => () => { @@ -399,10 +398,10 @@ export class SovranStorage implements Storage { storeId: `${this.storeId}-pendingEvents`, persistor: this.storePersistor, saveDelay: this.storePersistorSaveDelay, - onInitialized: markAsReadyGenerator('hasRestoredPendingEvents') - } + onInitialized: markAsReadyGenerator('hasRestoredPendingEvents'), + }, } - ) + ); this.pendingEvents = { get: createStoreGetter(this.pendingStore), @@ -416,21 +415,18 @@ export class SovranStorage implements Storage { } else { newState = [...value]; } - return newState - }) + return newState; + }); }, add: (event: SegmentEvent) => { - return this.pendingStore.dispatch((events) => ([ - ...events, - event - ])) + return this.pendingStore.dispatch((events) => [...events, event]); }, remove: (event: SegmentEvent) => { - return this.pendingStore.dispatch((events) => + return this.pendingStore.dispatch((events) => events.filter((e) => e.messageId != event.messageId) - ) - } - } + ); + }, + }; registerBridgeStore({ store: this.userInfoStore, diff --git a/packages/core/src/storage/types.ts b/packages/core/src/storage/types.ts index cc7edbfe2..2d83f5aba 100644 --- a/packages/core/src/storage/types.ts +++ b/packages/core/src/storage/types.ts @@ -85,8 +85,8 @@ export interface Storage { readonly deepLinkData: Watchable; - readonly pendingEvents: Watchable & - Settable & + readonly pendingEvents: Watchable & + Settable & Queue; } export type DeepLinkData = { diff --git a/packages/core/src/test-helpers/mockSegmentStore.ts b/packages/core/src/test-helpers/mockSegmentStore.ts index 84e78bd21..ff7a5080d 100644 --- a/packages/core/src/test-helpers/mockSegmentStore.ts +++ b/packages/core/src/test-helpers/mockSegmentStore.ts @@ -49,7 +49,7 @@ const INITIAL_VALUES: StoreData = { referring_application: '', url: '', }, - pendingEvents: [] + pendingEvents: [], }; export function createMockStoreGetter(fn: () => T) { @@ -194,29 +194,33 @@ export class MockSegmentStore implements Storage { this.callbacks.deepLinkData.register(callback), }; - readonly pendingEvents: Watchable & Settable & Queue = { + readonly pendingEvents: Watchable & + Settable & + Queue = { get: createMockStoreGetter(() => { - return this.data.pendingEvents + return this.data.pendingEvents; }), set: (value) => { this.data.pendingEvents = value instanceof Function ? value(this.data.pendingEvents ?? []) - : [ ...value ]; - this.callbacks.pendingEvents.run(this.data.pendingEvents) - return this.data.pendingEvents + : [...value]; + this.callbacks.pendingEvents.run(this.data.pendingEvents); + return this.data.pendingEvents; }, add: (value: SegmentEvent) => { this.data.pendingEvents.push(value); this.callbacks.pendingEvents.run(this.data.pendingEvents); - return Promise.resolve(this.data.pendingEvents) + return Promise.resolve(this.data.pendingEvents); }, remove: (value: SegmentEvent) => { - this.data.pendingEvents = this.data.pendingEvents.filter((e) => e.messageId != value.messageId) + this.data.pendingEvents = this.data.pendingEvents.filter( + (e) => e.messageId != value.messageId + ); this.callbacks.pendingEvents.run(this.data.pendingEvents); - return Promise.resolve(this.data.pendingEvents) + return Promise.resolve(this.data.pendingEvents); }, - onChange: (callback: (value: SegmentEvent[]) => void) => + onChange: (callback: (value: SegmentEvent[]) => void) => this.callbacks.pendingEvents.register(callback), - } + }; } diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 277632c06..2858bff9a 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -9,7 +9,7 @@ const sizeOf = (obj: unknown): number => { export const warnMissingNativeModule = () => { const MISSING_NATIVE_MODULE_WARNING = - `The package 'analytics-react-native' can't access a custom native module. Make sure: \n\n` + + "The package 'analytics-react-native' can't access a custom native module. Make sure: \n\n" + Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + '- You rebuilt the app after installing the package\n' + '- You are not using Expo managed workflow\n'; @@ -18,7 +18,9 @@ export const warnMissingNativeModule = () => { export const getNativeModule = (moduleName: string) => { const module = (NativeModules[moduleName] as NativeModule) ?? undefined; - if (module === undefined) warnMissingNativeModule(); + if (module === undefined) { + warnMissingNativeModule(); + } return module; }; @@ -143,7 +145,6 @@ export function isDate(value: unknown): value is Date { export function objectToString(value: object, json = true): string | undefined { // If the object has a custom toString we well use that if (value.toString !== Object.prototype.toString) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string return value.toString(); } if (json) { diff --git a/packages/plugins/plugin-branch/src/methods/parameterMapping.ts b/packages/plugins/plugin-branch/src/methods/parameterMapping.ts index 40cb40ecd..04c595d47 100644 --- a/packages/plugins/plugin-branch/src/methods/parameterMapping.ts +++ b/packages/plugins/plugin-branch/src/methods/parameterMapping.ts @@ -64,7 +64,7 @@ const onlyStrings = (value: unknown): string | undefined => { if (value === null || value === undefined) { return undefined; } - // eslint-disable-next-line @typescript-eslint/no-base-to-string + return `${value.toString()}`; }; diff --git a/packages/plugins/plugin-branch/src/methods/util.ts b/packages/plugins/plugin-branch/src/methods/util.ts index 1b7f97889..a27389b21 100644 --- a/packages/plugins/plugin-branch/src/methods/util.ts +++ b/packages/plugins/plugin-branch/src/methods/util.ts @@ -3,7 +3,9 @@ import branch, { BranchEvent } from 'react-native-branch'; import { mapEventProps, Product, transformMap } from './parameterMapping'; function toJSONString(value: unknown): string { - if (typeof value === 'string') return value; + if (typeof value === 'string') { + return value; + } return JSON.stringify(value); } @@ -42,7 +44,9 @@ export async function createBranchEventWithProps( const customData = {} as { [key: string]: string }; const branchData = {} as { [key: string]: unknown }; for (const key in eventProps) { - if (key in mapEventProps) branchData[key] = eventProps[key]; + if (key in mapEventProps) { + branchData[key] = eventProps[key]; + } customData[key] = toJSONString(eventProps[key]); } eventProps = { diff --git a/packages/plugins/plugin-device-token/src/DeviceTokenPlugin.tsx b/packages/plugins/plugin-device-token/src/DeviceTokenPlugin.tsx index b3c062150..3cfcb89e6 100644 --- a/packages/plugins/plugin-device-token/src/DeviceTokenPlugin.tsx +++ b/packages/plugins/plugin-device-token/src/DeviceTokenPlugin.tsx @@ -20,11 +20,7 @@ export class DeviceTokenPlugin extends PlatformPlugin { try { const isAuthorized = await this.authStatus; - if ( - isAuthorized !== undefined && - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison -- enum comparison not working with Firebase types - isAuthorized > 0 - ) { + if (isAuthorized !== undefined && isAuthorized > 0) { const token = await this.getDeviceToken(); if (token !== undefined) { @@ -70,11 +66,7 @@ export class DeviceTokenPlugin extends PlatformPlugin { async updatePermissionStatus() { const isAuthorized = await this.checkUserPermission(); - if ( - isAuthorized !== undefined && - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison -- enum comparison not working with Firebase types - isAuthorized > 0 - ) { + if (isAuthorized !== undefined && isAuthorized > 0) { const token = await this.getDeviceToken(); if (token !== undefined) { diff --git a/packages/plugins/plugin-firebase/src/FirebasePlugin.tsx b/packages/plugins/plugin-firebase/src/FirebasePlugin.tsx index dbba370ab..cb47226fd 100644 --- a/packages/plugins/plugin-firebase/src/FirebasePlugin.tsx +++ b/packages/plugins/plugin-firebase/src/FirebasePlugin.tsx @@ -38,8 +38,7 @@ export class FirebasePlugin extends DestinationPlugin { acc[trait] = typeof eventTraits[trait] === 'undefined' ? '' - : // eslint-disable-next-line @typescript-eslint/no-base-to-string - eventTraits[trait]!.toString(); + : eventTraits[trait]!.toString(); } return acc; }, diff --git a/packages/plugins/plugin-idfa/src/IdfaPlugin.tsx b/packages/plugins/plugin-idfa/src/IdfaPlugin.tsx index b3d556c03..4cc8bb08d 100644 --- a/packages/plugins/plugin-idfa/src/IdfaPlugin.tsx +++ b/packages/plugins/plugin-idfa/src/IdfaPlugin.tsx @@ -28,9 +28,9 @@ export class IdfaPlugin extends Plugin { } } - /** `requestTrackingPermission()` will prompt the user for -tracking permission and returns a promise you can use to -make additional tracking decisions based on the user response + /** `requestTrackingPermission()` will prompt the user for +tracking permission and returns a promise you can use to +make additional tracking decisions based on the user response */ async requestTrackingPermission(): Promise { try { diff --git a/packages/shared/src/setup.ts b/packages/shared/src/setup.ts index a30b696bb..7ca551581 100644 --- a/packages/shared/src/setup.ts +++ b/packages/shared/src/setup.ts @@ -3,6 +3,5 @@ jest.mock('react-native'); jest.mock('uuid'); jest.mock('react-native-get-random-values'); jest.mock('@react-native-async-storage/async-storage', () => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return require('@react-native-async-storage/async-storage/jest/async-storage-mock') ); diff --git a/packages/sovran/src/index.tsx b/packages/sovran/src/index.tsx index da250ca2c..83c0e85b9 100644 --- a/packages/sovran/src/index.tsx +++ b/packages/sovran/src/index.tsx @@ -8,7 +8,7 @@ import { import { onStoreAction } from './bridge'; const LINKING_ERROR = - `The package 'sovran-react-native' doesn't seem to be linked. Make sure: \n\n` + + "The package 'sovran-react-native' doesn't seem to be linked. Make sure: \n\n" + Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + '- You rebuilt the app after installing the package\n' + '- You are not using Expo managed workflow\n'; diff --git a/packages/sovran/src/persistor/async-storage-persistor.ts b/packages/sovran/src/persistor/async-storage-persistor.ts index 51814b333..0ec8b7934 100644 --- a/packages/sovran/src/persistor/async-storage-persistor.ts +++ b/packages/sovran/src/persistor/async-storage-persistor.ts @@ -6,7 +6,7 @@ let AsyncStorage: { } | null; try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-var-requires + // eslint-disable-next-line @typescript-eslint/no-var-requires AsyncStorage = require('@react-native-async-storage/async-storage').default; } catch (error) { AsyncStorage = null; diff --git a/packages/sovran/src/store.ts b/packages/sovran/src/store.ts index 4ac5d4723..99967853a 100644 --- a/packages/sovran/src/store.ts +++ b/packages/sovran/src/store.ts @@ -102,7 +102,9 @@ export const createStore = ( initialState: T, config?: StoreConfig ): Store => { - let state: T = Array.isArray(initialState) ? [...initialState] as T : {...initialState} as T; + let state: T = Array.isArray(initialState) + ? ([...initialState] as T) + : ({ ...initialState } as T); const queue: { call: Action; finally?: (newState: T) => void }[] = []; const isPersisted = config?.persist !== undefined; let saveTimeout: ReturnType | undefined; @@ -163,7 +165,7 @@ export const createStore = ( function getState(safe: true): Promise; function getState(safe?: boolean): T | Promise { if (safe !== true) { - return Array.isArray(state) ? [...state] as T: { ...state }; + return Array.isArray(state) ? ([...state] as T) : { ...state }; } return new Promise((resolve) => { queue.push({