diff --git a/CHANGES.txt b/CHANGES.txt index 0346c5e..441f867 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,13 @@ +1.14.0 (September 13, 2024) + - Added `status` property to Split reducer's slice of state to track the SDK events of non-default clients (Related to https://github.com/splitio/redux-client/issues/113). + - Added `lastUpdate` and `isTimedout` properties to the object returned by the `getStatus` helper and `selectTreatmentAndStatus` and `selectTreatmentWithConfigAndStatus` selectors, to expose the last event timestamp and the timedout status of the SDK clients (Related to https://github.com/splitio/redux-client/issues/113). + - Added `selectStatus` selector to retrieve the status properties of the SDK manager and clients from the Split state. + - Added remaining TypeScript types and interfaces to the library index exports, allowing them to be imported from the library index in TypeScript, e.g., `import type { IInitSplitSdkParams } from '@splitsoftware/splitio-redux'`. + - Updated `selectTreatmentAndStatus` and `selectTreatmentWithConfigAndStatus` selectors to retrieve status properties from the state rather than the SDK client instances directly. + - Updated @splitsoftware/splitio package to version 10.28.0 that includes minor updates: + - Added `sync.requestOptions.getHeaderOverrides` configuration option to enhance SDK HTTP request Headers for Authorization Frameworks. + - Updated some transitive dependencies for vulnerability fixes. + 1.13.0 (May 24, 2024) - Added a new `getStatus` helper function to retrieve the status properties of the SDK manager and clients: `isReady`, `isReadyFromCache`, `hasTimedout`, and `isDestroyed`. - Added new `selectTreatmentAndStatus` and `selectTreatmentWithConfigAndStatus` selectors as alternatives to the `selectTreatmentValue` and `selectTreatmentWithConfig` selectors, respectively. diff --git a/jest.config.js b/jest.config.js index 8552e53..7fdf1b5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,5 +16,8 @@ module.exports = { collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/__tests__/**', - ] + ], + + // Custom jest matcher + setupFilesAfterEnv: ['/src/__tests__/utils/toBeWithinRange.ts'], }; diff --git a/package-lock.json b/package-lock.json index 76a4c02..0df10ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,20 @@ { "name": "@splitsoftware/splitio-redux", - "version": "1.13.0", + "version": "1.14.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-redux", - "version": "1.13.0", + "version": "1.14.0", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio": "10.26.0", + "@splitsoftware/splitio": "10.28.0", "tslib": "^2.3.1" }, "devDependencies": { "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", - "@types/jest": "^27.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/redux-mock-store": "^1.0.1", @@ -1503,11 +1502,11 @@ } }, "node_modules/@splitsoftware/splitio": { - "version": "10.26.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-10.26.0.tgz", - "integrity": "sha512-sACjAcov/Zn1gYaN6m0qQb9G/LDk43c8rEzFaabhlnWOsH0W22ImVHGx8iU3I/DyC1S2wrsjXTSnW1GQlbb7+Q==", + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-10.28.0.tgz", + "integrity": "sha512-hzBnBZHmUTXvyMBbVTDUYtspLHjyjb/YqKtetNh7pAvkmj37vOXyXfF50Of5jr3Qmvdo0YFbKvMIOEXlXSGWaQ==", "dependencies": { - "@splitsoftware/splitio-commons": "1.14.0", + "@splitsoftware/splitio-commons": "1.17.0", "@types/google.analytics": "0.0.40", "@types/ioredis": "^4.28.0", "bloom-filters": "^3.0.0", @@ -1520,15 +1519,12 @@ "engines": { "node": ">=6", "npm": ">=3" - }, - "optionalDependencies": { - "eventsource": "^1.1.2" } }, "node_modules/@splitsoftware/splitio-commons": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.14.0.tgz", - "integrity": "sha512-ANP0NRPAMehi4bUQsb19kP5W5NVuCYUKRsDC5Nl78xHIu6cskAej1rXkjsocLnWerz2rO0H9kMjRKZj9lVsvKA==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.17.0.tgz", + "integrity": "sha512-rvP+0LGUN92bcTytiqyVxq9UzBG5kTkIYjU7b7AU2awBUYgM0bqT3xhQ9/MJ/2fsBbqC6QIsxoKDOz9pMgbAQw==", "dependencies": { "tslib": "^2.3.1" }, @@ -2705,12 +2701,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4227,15 +4223,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventsource": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.2.tgz", - "integrity": "sha512-xAH3zWhgO2/3KIniEKYPr8plNSzlGINOUqYj0m0u7AB81iRw8b/3E73W6AuU+6klLbaSFmZnaETQ2lXPfAydrA==", - "optional": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/execa": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", @@ -4369,9 +4356,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -7744,13 +7731,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" @@ -8252,9 +8239,9 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", - "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "engines": { "node": ">=8.6" @@ -9866,9 +9853,9 @@ } }, "node_modules/ws": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", - "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "engines": { "node": ">=8.3.0" @@ -11056,15 +11043,14 @@ } }, "@splitsoftware/splitio": { - "version": "10.26.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-10.26.0.tgz", - "integrity": "sha512-sACjAcov/Zn1gYaN6m0qQb9G/LDk43c8rEzFaabhlnWOsH0W22ImVHGx8iU3I/DyC1S2wrsjXTSnW1GQlbb7+Q==", + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-10.28.0.tgz", + "integrity": "sha512-hzBnBZHmUTXvyMBbVTDUYtspLHjyjb/YqKtetNh7pAvkmj37vOXyXfF50Of5jr3Qmvdo0YFbKvMIOEXlXSGWaQ==", "requires": { - "@splitsoftware/splitio-commons": "1.14.0", + "@splitsoftware/splitio-commons": "1.17.0", "@types/google.analytics": "0.0.40", "@types/ioredis": "^4.28.0", "bloom-filters": "^3.0.0", - "eventsource": "^1.1.2", "ioredis": "^4.28.0", "js-yaml": "^3.13.1", "node-fetch": "^2.7.0", @@ -11073,9 +11059,9 @@ } }, "@splitsoftware/splitio-commons": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.14.0.tgz", - "integrity": "sha512-ANP0NRPAMehi4bUQsb19kP5W5NVuCYUKRsDC5Nl78xHIu6cskAej1rXkjsocLnWerz2rO0H9kMjRKZj9lVsvKA==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.17.0.tgz", + "integrity": "sha512-rvP+0LGUN92bcTytiqyVxq9UzBG5kTkIYjU7b7AU2awBUYgM0bqT3xhQ9/MJ/2fsBbqC6QIsxoKDOz9pMgbAQw==", "requires": { "tslib": "^2.3.1" } @@ -11969,12 +11955,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "browser-process-hrtime": { @@ -13108,12 +13094,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, - "eventsource": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.2.tgz", - "integrity": "sha512-xAH3zWhgO2/3KIniEKYPr8plNSzlGINOUqYj0m0u7AB81iRw8b/3E73W6AuU+6klLbaSFmZnaETQ2lXPfAydrA==", - "optional": true - }, "execa": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", @@ -13227,9 +13207,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -15717,13 +15697,13 @@ "dev": true }, "micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "requires": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" + "braces": "^3.0.3", + "picomatch": "^2.3.1" } }, "mime-db": { @@ -16096,9 +16076,9 @@ "dev": true }, "picomatch": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", - "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, "pirates": { @@ -17313,9 +17293,9 @@ } }, "ws": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", - "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index 9da3da2..a1715c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-redux", - "version": "1.13.0", + "version": "1.14.0", "description": "A library to easily use Split JS SDK with Redux and React Redux", "main": "lib/index.js", "module": "es/index.js", @@ -59,13 +59,12 @@ }, "homepage": "https://github.com/splitio/redux-client#readme", "dependencies": { - "@splitsoftware/splitio": "10.26.0", + "@splitsoftware/splitio": "10.28.0", "tslib": "^2.3.1" }, "devDependencies": { "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", - "@types/jest": "^27.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/redux-mock-store": "^1.0.1", diff --git a/src/__tests__/actions.browser.test.ts b/src/__tests__/asyncActions.browser.test.ts similarity index 75% rename from src/__tests__/actions.browser.test.ts rename to src/__tests__/asyncActions.browser.test.ts index 1c114cb..d6ee032 100644 --- a/src/__tests__/actions.browser.test.ts +++ b/src/__tests__/asyncActions.browser.test.ts @@ -49,9 +49,12 @@ describe('initSplitSdk', () => { actionResult.then(() => { // return of async action let action = store.getActions()[0]; - expect(action.type).toEqual(SPLIT_READY); - expect(action.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(action.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(action).toEqual({ + type: SPLIT_READY, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1), + } + }); expect((SplitFactory as jest.Mock).mock.calls.length).toBe(1); expect(onReadyCb.mock.calls.length).toBe(1); @@ -59,9 +62,12 @@ describe('initSplitSdk', () => { (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_UPDATE); setTimeout(() => { action = store.getActions()[1]; - expect(action.type).toEqual(SPLIT_UPDATE); - expect(action.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(action.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(action).toEqual({ + type: SPLIT_UPDATE, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1), + } + }); expect(onUpdateCb.mock.calls.length).toBe(1); done(); }, 0); @@ -79,9 +85,12 @@ describe('initSplitSdk', () => { actionResult.catch(() => { // return of async action let action = store.getActions()[0]; - expect(action.type).toEqual(SPLIT_TIMEDOUT); - expect(action.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(action.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(action).toEqual({ + type: SPLIT_TIMEDOUT, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1), + } + }); expect((SplitFactory as jest.Mock).mock.calls.length).toBe(1); expect(onTimedoutCb.mock.calls.length).toBe(1); @@ -89,9 +98,12 @@ describe('initSplitSdk', () => { (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); setTimeout(() => { action = store.getActions()[1]; - expect(action.type).toEqual(SPLIT_READY); - expect(action.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(action.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(action).toEqual({ + type: SPLIT_READY, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1), + } + }); expect(onReadyCb.mock.calls.length).toBe(1); done(); }, 0); @@ -105,13 +117,21 @@ describe('initSplitSdk', () => { const onReadyFromCacheCb = jest.fn(() => { // action should be already dispatched when the callback is called const action = store.getActions()[0]; - expect(action.type).toEqual(SPLIT_READY_FROM_CACHE); - expect(action.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(action.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(action).toEqual({ + type: SPLIT_READY_FROM_CACHE, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1), + } + }); }); const onReadyCb = jest.fn(() => { const action = store.getActions()[1]; - expect(action.type).toEqual(SPLIT_READY); + expect(action).toEqual({ + type: SPLIT_READY, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1), + } + }); }); const actionResult = store.dispatch(initSplitSdk({ config: sdkBrowserConfig, onReady: onReadyCb, onReadyFromCache: onReadyFromCacheCb })); @@ -187,19 +207,27 @@ describe('getTreatments', () => { actionResult.then(() => { store.dispatch(getTreatments({ splitNames: 'split1' })); - store.dispatch(getTreatments({ flagSets: 'set1' })); + store.dispatch(getTreatments({ splitNames: ['split2'], key: sdkBrowserConfig.core.key })); + store.dispatch(getTreatments({ flagSets: 'set1', key: { matchingKey: sdkBrowserConfig.core.key as string, bucketingKey: 'bucket' } })); - const actions = [store.getActions()[1], store.getActions()[2]]; + const actions = [store.getActions()[1], store.getActions()[2], store.getActions()[3]]; actions.forEach(action => { - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object) + } + }); }); // getting the evaluation result and validating it matches the results from SDK - expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split1'], undefined); - expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(actions[0].payload.treatments); + expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenCalledWith(['split1'], undefined); + expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveReturnedWith(actions[0].payload.treatments); + expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split2'], undefined); + expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(actions[1].payload.treatments); expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toHaveBeenLastCalledWith(['set1'], undefined); - expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toHaveLastReturnedWith(actions[1].payload.treatments); + expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toHaveLastReturnedWith(actions[2].payload.treatments); expect(getClient(splitSdk).evalOnUpdate).toEqual({}); expect(getClient(splitSdk).evalOnReady.length).toEqual(0); @@ -217,21 +245,31 @@ describe('getTreatments', () => { function onReadyFromCacheCb() { // dispatching multiple ADD_TREATMENTS actions - store.dispatch(getTreatments({ splitNames: 'split1' })); // single feature flag name + store.dispatch(getTreatments({ splitNames: 'split1', key: sdkBrowserConfig.core.key })); // single feature flag name const attributes = { att1: 'att1' }; store.dispatch(getTreatments({ splitNames: ['split2', 'split3'], attributes })); // list of feature flag names with attributes // getting the 1st evaluation result and validating it matches the results from SDK let action = store.getActions()[1]; // action 0 is SPLIT_READY_FROM_CACHE - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object) + } + }); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenNthCalledWith(1, ['split1'], undefined); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveNthReturnedWith(1, action.payload.treatments); // getting the 2nd evaluation result and validating it matches the results from SDK action = store.getActions()[2]; - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object) + } + }); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenNthCalledWith(2, ['split2', 'split3'], attributes); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveNthReturnedWith(2, action.payload.treatments); expect(getClient(splitSdk).evalOnUpdate).toEqual({}); // control assertion - cbs scheduled for update @@ -242,21 +280,26 @@ describe('getTreatments', () => { actionResult.then(() => { // The SPLIT_READY_WITH_EVALUATIONS action is dispatched if the SDK is ready and there are pending evaluations. action = store.getActions()[3]; - expect(action.type).toBe(SPLIT_READY_WITH_EVALUATIONS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); + expect(action).toEqual({ + type: SPLIT_READY_WITH_EVALUATIONS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object), + timestamp: expect.any(Number) + } + }); // Multiple evaluations where registered, but only one SPLIT_READY_WITH_EVALUATIONS action is dispatched expect(store.getActions().length).toBe(4); // getting the evaluation result and validating it matches the results from SDK calls - const treatments = action.payload.treatments; expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenNthCalledWith(3, ['split1'], undefined); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenNthCalledWith(4, ['split2', 'split3'], attributes); const expectedTreatments = { ...(splitSdk.factory.client().getTreatmentsWithConfig as jest.Mock).mock.results[2].value, ...(splitSdk.factory.client().getTreatmentsWithConfig as jest.Mock).mock.results[3].value, }; - expect(treatments).toEqual(expectedTreatments); + expect(action.payload.treatments).toEqual(expectedTreatments); expect(splitSdk.factory.client().getTreatmentsWithConfig).toBeCalledTimes(4); // control assertion - getTreatmentsWithConfig calls expect(getClient(splitSdk).evalOnUpdate).toEqual({}); // control assertion - cbs scheduled for update @@ -303,7 +346,12 @@ describe('getTreatments', () => { function onReadyFromCacheCb() { expect(store.getActions().length).toBe(3); const action = store.getActions()[2]; - expect(action.type).toBe(SPLIT_READY_FROM_CACHE); + expect(action).toEqual({ + type: SPLIT_READY_FROM_CACHE, + payload: { + timestamp: expect.any(Number) + } + }); } (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); @@ -311,8 +359,14 @@ describe('getTreatments', () => { actionResult.then(() => { // The SPLIT_READY_WITH_EVALUATIONS action is dispatched if the SDK is ready and there are pending evaluations. const action = store.getActions()[3]; - expect(action.type).toBe(SPLIT_READY_WITH_EVALUATIONS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); + expect(action).toEqual({ + type: SPLIT_READY_WITH_EVALUATIONS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object), + timestamp: expect.any(Number) + } + }); // getting the evaluation result and validating it matches the results from SDK const treatments = action.payload.treatments; @@ -347,9 +401,13 @@ describe('getTreatments', () => { // If SDK is not ready, an ADD_TREATMENTS action is dispatched with control treatments without calling SDK client expect(store.getActions().length).toBe(1); let action = store.getActions()[0]; - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); - expect(action.payload.treatments).toEqual(getControlTreatmentsWithConfig(['split3'])); + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: getControlTreatmentsWithConfig(['split3']) + } + }); expect(splitSdk.factory.client().getTreatmentsWithConfig).toBeCalledTimes(0); // the item is added for evaluation on SDK_READY, and also on SDK_READY_FROM_CACHE and SDK_UPDATE events @@ -362,7 +420,12 @@ describe('getTreatments', () => { (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT); function onTimedoutCb() { action = store.getActions()[1]; - expect(action.type).toBe(SPLIT_TIMEDOUT); + expect(action).toEqual({ + type: SPLIT_TIMEDOUT, + payload: { + timestamp: expect.any(Number) + } + }); } // When the SDK is ready from cache, the SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS action is dispatched instead of @@ -370,8 +433,14 @@ describe('getTreatments', () => { (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE); function onReadyFromCacheCb() { action = store.getActions()[2]; - expect(action.type).toBe(SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); + expect(action).toEqual({ + type: SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object), + timestamp: expect.any(Number) + } + }); // getting the evaluation result and validating it matches the results from SDK const treatments = action.payload.treatments; @@ -385,8 +454,14 @@ describe('getTreatments', () => { function onReadyCb() { // The SPLIT_READY_WITH_EVALUATIONS action is dispatched if the SDK is ready and there are pending evaluations. action = store.getActions()[3]; - expect(action.type).toBe(SPLIT_READY_WITH_EVALUATIONS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); + expect(action).toEqual({ + type: SPLIT_READY_WITH_EVALUATIONS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object), + timestamp: expect.any(Number) + } + }); // getting the evaluation result and validating it matches the results from SDK let treatments = action.payload.treatments; @@ -398,8 +473,14 @@ describe('getTreatments', () => { // Triggering an update dispatches SPLIT_UPDATE_WITH_EVALUATIONS (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_UPDATE); action = store.getActions()[4]; - expect(action.type).toBe(SPLIT_UPDATE_WITH_EVALUATIONS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); + expect(action).toEqual({ + type: SPLIT_UPDATE_WITH_EVALUATIONS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object), + timestamp: expect.any(Number) + } + }); // getting the evaluation result and validating it matches the results from SDK treatments = action.payload.treatments; @@ -409,15 +490,26 @@ describe('getTreatments', () => { expect(Object.values(getClient(splitSdk).evalOnUpdate).length).toBe(1); // control assertion - still have one evalOnUpdate subscription // We deregister the item from evalOnUpdate. - store.dispatch(getTreatments({ splitNames: 'split3', evalOnUpdate: false })); + store.dispatch(getTreatments({ splitNames: 'split3', evalOnUpdate: false, key: { matchingKey: sdkBrowserConfig.core.key as string, bucketingKey: 'bucket' } })); action = store.getActions()[5]; - expect(action.type).toBe(ADD_TREATMENTS); + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: sdkBrowserConfig.core.key, + treatments: expect.any(Object) + } + }); expect(Object.values(getClient(splitSdk).evalOnUpdate).length).toBe(0); // control assertion - removed evalOnUpdate subscription // Now, SDK_UPDATE events do not trigger SPLIT_UPDATE_WITH_EVALUATIONS but SPLIT_UPDATE instead (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_UPDATE); action = store.getActions()[6]; - expect(action.type).toBe(SPLIT_UPDATE); + expect(action).toEqual({ + type: SPLIT_UPDATE, + payload: { + timestamp: expect.any(Number) + } + }); expect(store.getActions().length).toBe(7); // control assertion - no more actions after the update. expect(splitSdk.factory.client().getTreatmentsWithConfig).toBeCalledTimes(4); // control assertion - called 4 times, in actions SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS, SPLIT_READY_WITH_EVALUATIONS, SPLIT_UPDATE_WITH_EVALUATIONS and ADD_TREATMENTS. @@ -426,108 +518,120 @@ describe('getTreatments', () => { } }); -}); - -describe('getTreatments providing a user key', () => { - - beforeEach(clearSplitSdk); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('if Split SDK is ready and is provided with the same user key than the main client, it dispatches an ADD_TREATMENTS action as main client', (done) => { - - // Init SDK and set ready - const store = mockStore(STATE_INITIAL); - const actionResult = store.dispatch(initSplitSdk({ config: sdkBrowserConfig })); - (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); - - actionResult.then(() => { - store.dispatch(getTreatments({ splitNames: 'split1', key: sdkBrowserConfig.core.key })); - - const action = store.getActions()[1]; - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe(sdkBrowserConfig.core.key); - expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments); - expect(getClient(splitSdk).evalOnUpdate).toEqual({}); - expect(getClient(splitSdk).evalOnReady.length).toEqual(0); - - done(); - }); - }); - - it('if Split SDK is ready but the user key is different than the main client, it stores control treatments (without calling SDK client), and registers pending evaluations to dispatch ADD_TREATMENTS actions when the new client is ready and updated', (done) => { + it('for non-default clients, it stores control treatments (without calling SDK client) and registers pending evaluations if the client is not operational, to dispatch it when ready from cache, ready, and updated (Using callbacks to assert that registered evaluations are not affected when the client timeouts)', (done) => { // Init SDK and set ready const store = mockStore(STATE_INITIAL); const actionResult = store.dispatch(initSplitSdk({ config: sdkBrowserConfig })); + (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE); (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); actionResult.then(() => { - // SPLIT_READY should have been dispatched - expect(store.getActions().length).toBe(1); - let action = store.getActions()[0]; - expect(action.type).toBe(SPLIT_READY); - - // If SDK is ready for the main key and a getTreatment is dispatched for a different user key: - // the item is added to the 'evalOnReady' list of the new client, + // SDK_READY_FROM_CACHE & SPLIT_READY should have been dispatched + expect(store.getActions()).toEqual([{ + type: SPLIT_READY_FROM_CACHE, payload: { timestamp: expect.any(Number) } + }, { + type: SPLIT_READY, payload: { timestamp: expect.any(Number) } + }]); + + // If getTreatment is dispatched for a different user key, the item is added to the 'evalOnReady' list of the new client store.dispatch(getTreatments({ splitNames: 'split2', key: 'other-user-key' })); - expect(getClient(splitSdk).evalOnReady.length).toEqual(0); // control assertion - no evaluations were registeres for SDK_READY on main client - expect(getClient(splitSdk, 'other-user-key').evalOnReady.length).toEqual(1); // control assertion - 1 evaluation was registeres for SDK_READY on the new client + expect(getClient(splitSdk).evalOnReady.length).toEqual(0); // control assertion - no evaluations were registered for SDK_READY on main client + expect(getClient(splitSdk, 'other-user-key').evalOnReady.length).toEqual(1); // control assertion - 1 evaluation was registered for SDK_READY on the new client expect(getClient(splitSdk).evalOnUpdate).toEqual({}); - // and an ADD_TREATMENTS action is dispatched with control treatments without calling SDK client. - action = store.getActions()[1]; - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe('other-user-key'); - expect(action.payload.treatments).toEqual(getControlTreatmentsWithConfig(['split2'])); - expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).toBeCalledTimes(0); + // If SDK was ready from cache, the SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS action is dispatched for the new clients, calling SDK client to evaluate from cache + let action = store.getActions()[2]; + expect(action).toEqual({ + type: SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS, + payload: { + key: 'other-user-key', + timestamp: 0, + treatments: expect.any(Object), + nonDefaultKey: true + } + }); + + expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).lastCalledWith(['split2'], undefined); + expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments); (splitSdk.factory as any).client('other-user-key').__emitter__.emit(Event.SDK_READY, 'other-user-key'); - // The ADD_TREATMENTS action is dispatched synchronously once the SDK is ready for the new user key - action = store.getActions()[2]; - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe('other-user-key'); + // The SPLIT_READY_WITH_EVALUATIONS action is dispatched synchronously once the SDK is ready for the new user key + action = store.getActions()[3]; + expect(action).toEqual({ + type: SPLIT_READY_WITH_EVALUATIONS, + payload: { + key: 'other-user-key', + treatments: expect.any(Object), + timestamp: expect.any(Number), + nonDefaultKey: true + } + }); // getting the evaluation result and validating it matches the results from SDK - const treatments = action.payload.treatments; expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).lastCalledWith(['split2'], undefined); - expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).toHaveLastReturnedWith(treatments); + expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments); expect(getClient(splitSdk).evalOnUpdate).toEqual({}); // control assertion // The getTreatments is dispatched again, but this time is evaluated with attributes and registered for 'evalOnUpdate' const attributes = { att1: 'att1' }; store.dispatch(getTreatments({ splitNames: 'split2', attributes, key: 'other-user-key', evalOnUpdate: true })); - action = store.getActions()[3]; - expect(action.type).toBe(ADD_TREATMENTS); + action = store.getActions()[4]; + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: 'other-user-key', + treatments: expect.any(Object) + } + }); expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).lastCalledWith(['split2'], attributes); expect(Object.values(getClient(splitSdk, 'other-user-key').evalOnUpdate).length).toBe(1); // control assertion - added evalOnUpdate subscription - // The ADD_TREATMENTS action is dispatched when the SDK is updated - // SPLIT_UPDATE is not triggered since it is an update for a shared client + // The SPLIT_UPDATE_WITH_EVALUATIONS action is dispatched when the SDK is updated for the new user key (splitSdk.factory as any).client('other-user-key').__emitter__.emit(Event.SDK_UPDATE); - action = store.getActions()[4]; - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe('other-user-key'); + action = store.getActions()[5]; + expect(action).toEqual({ + type: SPLIT_UPDATE_WITH_EVALUATIONS, + payload: { + key: 'other-user-key', + treatments: expect.any(Object), + timestamp: expect.any(Number), + nonDefaultKey: true + } + }); expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).lastCalledWith(['split2'], attributes); expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments); expect(Object.values(getClient(splitSdk, 'other-user-key').evalOnUpdate).length).toBe(1); // control assertion - keeping evalOnUpdate subscription // We deregister the item from evalOnUpdate. store.dispatch(getTreatments({ splitNames: 'split2', key: 'other-user-key', evalOnUpdate: false })); - action = store.getActions()[5]; - expect(action.type).toBe(ADD_TREATMENTS); + action = store.getActions()[6]; + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: 'other-user-key', + treatments: expect.any(Object) + } + }); expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).lastCalledWith(['split2'], undefined); expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments); expect(Object.values(getClient(splitSdk).evalOnUpdate).length).toBe(0); // control assertion - removed evalOnUpdate subscription - // Now, SDK_UPDATE events do not trigger ADD_TREATMENTS + // Now, SDK_UPDATE events do not trigger SPLIT_UPDATE_WITH_EVALUATIONS but SPLIT_UPDATE instead (splitSdk.factory as any).client('other-user-key').__emitter__.emit(Event.SDK_UPDATE); - expect(store.getActions().length).toBe(6); // control assertion - no more actions after the update. - expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).toBeCalledTimes(4); // control assertion - called 4 times + action = store.getActions()[7]; + expect(action).toEqual({ + type: SPLIT_UPDATE, + payload: { + key: 'other-user-key', + timestamp: expect.any(Number) + } + }); + + expect(store.getActions().length).toBe(8); // control assertion - no more actions after the update. + expect(splitSdk.factory.client('other-user-key').getTreatmentsWithConfig).toBeCalledTimes(5); // control assertion - called 5 times, in actions SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS, SPLIT_READY_WITH_EVALUATIONS, ADD_TREATMENTS, SPLIT_UPDATE_WITH_EVALUATIONS and ADD_TREATMENTS. done(); }); @@ -568,9 +672,12 @@ describe('destroySplitSdk', () => { actionResult.then(() => { const action = store.getActions()[3]; - expect(action.type).toEqual(SPLIT_DESTROY); - expect(action.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(action.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(action).toEqual({ + type: SPLIT_DESTROY, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1), + } + }); // assert that all client's destroy methods were called expect(splitSdk.factory.client().destroy).toBeCalledTimes(1); expect(splitSdk.factory.client('other-user-key').destroy).toBeCalledTimes(1); @@ -593,7 +700,12 @@ describe('destroySplitSdk', () => { expect(splitSdk.factory.client().destroy).toBeCalledTimes(1); const action = store.getActions()[1]; - expect(action.type).toEqual(SPLIT_DESTROY); + expect(action).toEqual({ + type: SPLIT_DESTROY, + payload: { + timestamp: expect.any(Number), + } + }); done(); } }); diff --git a/src/__tests__/actions.node.test.ts b/src/__tests__/asyncActions.node.test.ts similarity index 86% rename from src/__tests__/actions.node.test.ts rename to src/__tests__/asyncActions.node.test.ts index 3d99a73..7bf6f0d 100644 --- a/src/__tests__/actions.node.test.ts +++ b/src/__tests__/asyncActions.node.test.ts @@ -53,9 +53,12 @@ describe('initSplitSdk', () => { // Action is dispatched synchronously const action = store.getActions()[0]; - expect(action.type).toEqual(SPLIT_READY); - expect(action.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(action.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(action).toEqual({ + type: SPLIT_READY, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now()), + } + }); } // create multiple stores @@ -86,9 +89,12 @@ describe('initSplitSdk', () => { store.dispatch(initSplitSdkAction); const action = store.getActions()[0]; - expect(action.type).toEqual(SPLIT_TIMEDOUT); - expect(action.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(action.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(action).toEqual({ + type: SPLIT_TIMEDOUT, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1) + } + }); expect((SplitFactory as jest.Mock).mock.calls.length).toBe(1); timestamp = Date.now(); @@ -105,14 +111,20 @@ describe('initSplitSdk', () => { // Actions are dispatched synchronously const timeoutAction = store.getActions()[0]; - expect(timeoutAction.type).toEqual(SPLIT_TIMEDOUT); - expect(timeoutAction.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(timeoutAction.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(timeoutAction).toEqual({ + type: SPLIT_TIMEDOUT, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1) + } + }); const readyAction = store.getActions()[1]; - expect(readyAction.type).toEqual(SPLIT_READY); - expect(readyAction.payload.timestamp).toBeLessThanOrEqual(Date.now()); - expect(readyAction.payload.timestamp).toBeGreaterThanOrEqual(timestamp); + expect(readyAction).toEqual({ + type: SPLIT_READY, + payload: { + timestamp: expect.toBeWithinRange(timestamp, Date.now() + 1) + } + }); } // create multiple stores @@ -171,8 +183,13 @@ describe('getTreatments', () => { const actions = [store.getActions()[1], store.getActions()[2]]; // action 0 is SPLIT_READY actions.forEach(action => { - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe(splitKey); + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: splitKey, + treatments: expect.any(Object) + } + }); }); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenLastCalledWith(splitKey, ['split1'], undefined); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(actions[0].payload.treatments); @@ -185,8 +202,13 @@ describe('getTreatments', () => { store.dispatch(getTreatments({ key: 'other_user', splitNames: featureFlagNames, attributes })); const action = store.getActions()[3]; - expect(action.type).toBe(ADD_TREATMENTS); - expect(action.payload.key).toBe('other_user'); + expect(action).toEqual({ + type: ADD_TREATMENTS, + payload: { + key: 'other_user', + treatments: expect.any(Object) + } + }); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenLastCalledWith('other_user', featureFlagNames, attributes); expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments); } @@ -241,9 +263,19 @@ describe('destroySplitSdk', () => { newStoreAfterDestroy.dispatch(initSplitSdkAction); let action = newStoreAfterDestroy.getActions()[0]; - expect(action.type).toEqual(SPLIT_READY); + expect(action).toEqual({ + type: SPLIT_READY, + payload: { + timestamp: expect.any(Number), + } + }); action = newStoreAfterDestroy.getActions()[1]; - expect(action.type).toEqual(SPLIT_DESTROY); + expect(action).toEqual({ + type: SPLIT_DESTROY, + payload: { + timestamp: expect.any(Number), + } + }); done(); } diff --git a/src/__tests__/connectSplit.test.ts b/src/__tests__/connectSplit.test.ts index 65ef223..3515234 100644 --- a/src/__tests__/connectSplit.test.ts +++ b/src/__tests__/connectSplit.test.ts @@ -13,7 +13,7 @@ import { connectSplit } from '../react-redux/connectSplit'; const FeatureComponent = createComponentWithExposedProps('FeatureComponent'); describe('connectSplit', () => { - it('should pass the Split piece of state and binded getTreatment as props', () => { + it('should pass the Split piece of state and bound getTreatment as props', () => { const store = mockStore(STATE_READY); const ConnectedFeatureComponent: React.ComponentType = connectSplit()(FeatureComponent); diff --git a/src/__tests__/helpers.browser.test.ts b/src/__tests__/helpers.browser.test.ts index feabf04..0cbdd09 100644 --- a/src/__tests__/helpers.browser.test.ts +++ b/src/__tests__/helpers.browser.test.ts @@ -192,13 +192,13 @@ describe('getStatus', () => { (splitSdk.factory as any).client('user_2').__emitter__.emit(Event.SDK_READY_FROM_CACHE); // Main client - const MAIN_CLIENT_STATUS = { ...STATUS_INITIAL, isReady: true, isOperational: true }; + const MAIN_CLIENT_STATUS = { ...STATUS_INITIAL, isReadyFromCache: true, isReady: true, isOperational: true, lastUpdate: (splitSdk.factory.client() as any).__getStatus().lastUpdate }; expect(getStatus()).toEqual(MAIN_CLIENT_STATUS); expect(getStatus(sdkBrowserConfig.core.key)).toEqual(MAIN_CLIENT_STATUS); expect(getStatus({ matchingKey: sdkBrowserConfig.core.key as string, bucketingKey: '' })).toEqual(MAIN_CLIENT_STATUS); // Client for user_2 - const USER_2_STATUS = { ...STATUS_INITIAL, isReadyFromCache: true, isOperational: true }; + const USER_2_STATUS = { ...STATUS_INITIAL, isReadyFromCache: true, isOperational: true, lastUpdate: (splitSdk.factory.client('user_2') as any).__getStatus().lastUpdate }; expect(getStatus('user_2')).toEqual(USER_2_STATUS); expect(getStatus({ matchingKey: 'user_2', bucketingKey: '' })).toEqual(USER_2_STATUS); diff --git a/src/__tests__/helpers.node.test.ts b/src/__tests__/helpers.node.test.ts index 9f90fcf..3741352 100644 --- a/src/__tests__/helpers.node.test.ts +++ b/src/__tests__/helpers.node.test.ts @@ -160,7 +160,7 @@ describe('getStatus', () => { initSplitSdk({ config: sdkNodeConfig }); (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); - const MAIN_CLIENT_STATUS = { ...STATUS_INITIAL, isReady: true, isOperational: true }; + const MAIN_CLIENT_STATUS = { ...STATUS_INITIAL, isReady: true, isOperational: true, lastUpdate: (splitSdk.factory.client() as any).__getStatus().lastUpdate }; expect(getStatus()).toEqual(MAIN_CLIENT_STATUS); expect(getStatus('ignored_key_in_server_side')).toEqual(MAIN_CLIENT_STATUS); }); diff --git a/src/__tests__/reducer.test.ts b/src/__tests__/reducer.test.ts index bc4ec53..07fb1b6 100644 --- a/src/__tests__/reducer.test.ts +++ b/src/__tests__/reducer.test.ts @@ -1,4 +1,4 @@ -import { splitReducer } from '../reducer'; +import { initialStatus, splitReducer } from '../reducer'; import { splitReady, splitReadyWithEvaluations, splitReadyFromCache, splitReadyFromCacheWithEvaluations, splitTimedout, splitUpdate, splitUpdateWithEvaluations, splitDestroy, addTreatments } from '../actions'; import { ISplitState } from '../types'; import SplitIO from '@splitsoftware/splitio/types/splitio'; @@ -38,75 +38,148 @@ describe('Split reducer', () => { }); it('should handle SPLIT_READY', () => { - const readyAction = splitReady(); - expect( - splitReducer(initialState, readyAction), - ).toEqual({ + const updatedState = splitReducer(initialState, splitReady(100)); + + // default key + expect(updatedState).toEqual({ ...initialState, isReady: true, - lastUpdate: readyAction.payload.timestamp, + lastUpdate: 100, + }); + + // non-default key + expect(splitReducer(updatedState, splitReady(200, { matchingKey: 'other_key', bucketingKey: 'bucketing' }))).toEqual({ + ...updatedState, + status: { + other_key: { + ...initialStatus, + isReady: true, + lastUpdate: 200, + } + } }); }); it('should handle SPLIT_READY_FROM_CACHE', () => { - const readyAction = splitReadyFromCache(); - expect( - splitReducer(initialState, readyAction), - ).toEqual({ - ...initialState, + const updatedState = splitReducer(initialState, splitReadyFromCache(200)); + + // default key + expect(updatedState).toEqual({ + ...updatedState, isReadyFromCache: true, - lastUpdate: readyAction.payload.timestamp, + lastUpdate: 200, + }); + + // non-default key + expect(splitReducer(updatedState, splitReadyFromCache(300, 'other_key'))).toEqual({ + ...updatedState, + status: { + other_key: { + ...initialStatus, + isReadyFromCache: true, + lastUpdate: 300, + } + } }); }); it('should handle SPLIT_TIMEDOUT', () => { - const timedoutAction = splitTimedout(); - expect( - splitReducer(initialState, timedoutAction), - ).toEqual({ + const updatedState = splitReducer(initialState, splitTimedout(300)); + + // default key + expect(updatedState).toEqual({ ...initialState, isTimedout: true, hasTimedout: true, - lastUpdate: timedoutAction.payload.timestamp, + lastUpdate: 300, + }); + + // non-default key + expect(splitReducer(updatedState, splitTimedout(400, 'other_key'))).toEqual({ + ...updatedState, + status: { + other_key: { + ...initialStatus, + isTimedout: true, + hasTimedout: true, + lastUpdate: 400, + } + } }); }); it('should handle SPLIT_READY after SPLIT_TIMEDOUT', () => { - const timedoutAction = splitTimedout(); - const readyAction = splitReady(); - expect( - splitReducer(splitReducer(initialState, timedoutAction), readyAction), - ).toEqual({ + const updatedState = splitReducer(splitReducer(initialState, splitTimedout(100)), splitReady(200)); + + // default key + expect(updatedState).toEqual({ ...initialState, isReady: true, isTimedout: false, hasTimedout: true, - lastUpdate: readyAction.payload.timestamp, + lastUpdate: 200, + }); + + // non-default key + expect(splitReducer(splitReducer(updatedState, splitTimedout(100, 'other_key')), splitReady(200, 'other_key'))).toEqual({ + ...updatedState, + status: { + other_key: { + ...initialStatus, + isReady: true, + isTimedout: false, + hasTimedout: true, + lastUpdate: 200, + } + } }); }); it('should handle SPLIT_UPDATE', () => { - const updateAction = splitUpdate(); - expect( - splitReducer(initialState, updateAction), - ).toEqual({ + const updatedState = splitReducer(initialState, splitUpdate(300)); + + // default key + expect(updatedState).toEqual({ ...initialState, - lastUpdate: updateAction.payload.timestamp, + lastUpdate: 300, + }); + + // non-default key + expect(splitReducer(updatedState, splitUpdate(400, 'other_key'))).toEqual({ + ...updatedState, + status: { + other_key: { + ...initialStatus, + lastUpdate: 400, + } + } }); }); it('should handle SPLIT_DESTROY', () => { - const destroyAction = splitDestroy(); - expect( - splitReducer(initialState, destroyAction), - ).toEqual({ + const updatedState = splitReducer(initialState, splitDestroy(400)); + + // default key + expect(updatedState).toEqual({ ...initialState, isDestroyed: true, - lastUpdate: destroyAction.payload.timestamp, + lastUpdate: 400, + }); + + // non-default key + expect(splitReducer(updatedState, splitDestroy(500, 'other_key'))).toEqual({ + ...updatedState, + status: { + other_key: { + ...initialStatus, + isDestroyed: true, + lastUpdate: 500, + } + } }); }); - const actionCreatorsWithEvaluations: Array<[string, (key: SplitIO.SplitKey, treatments: SplitIO.TreatmentsWithConfig) => AnyAction, boolean, boolean]> = [ + const actionCreatorsWithEvaluations: Array<[string, (key: SplitIO.SplitKey, treatments: SplitIO.TreatmentsWithConfig, timestamp: number, nonDefaultKey?: boolean) => AnyAction, boolean, boolean]> = [ ['ADD_TREATMENTS', addTreatments, false, false], ['SPLIT_READY_WITH_EVALUATIONS', splitReadyWithEvaluations, true, false], ['SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS', splitReadyFromCacheWithEvaluations, false, true], @@ -115,21 +188,37 @@ describe('Split reducer', () => { it.each(actionCreatorsWithEvaluations)('should handle %s', (_, actionCreator, isReady, isReadyFromCache) => { const initialTreatments = initialState.treatments; - const action = actionCreator(key, treatments); - // control assertion - reduced state has the expected shape - expect( - splitReducer(initialState, action), - ).toEqual({ + // default key + const action = actionCreator(key, treatments, 1000); + expect(splitReducer(initialState, action)).toEqual({ ...initialState, isReady, isReadyFromCache, - lastUpdate: action.payload.timestamp || initialState.lastUpdate, + lastUpdate: action.type === 'ADD_TREATMENTS' ? initialState.lastUpdate : 1000, + treatments: { + test_split: { + [key]: treatments.test_split, + }, + }, + }); + + // non-default key + expect(splitReducer(initialState, actionCreator(key, treatments, 1000, true))).toEqual({ + ...initialState, treatments: { test_split: { [key]: treatments.test_split, }, }, + status: action.type === 'ADD_TREATMENTS' ? undefined : { + [key]: { + ...initialStatus, + isReady, + isReadyFromCache, + lastUpdate: 1000, + } + } }); expect(initialState.treatments).toBe(initialTreatments); // control-assert initialState treatments object shouldn't be replaced @@ -142,7 +231,7 @@ describe('Split reducer', () => { const newTreatments: SplitIO.TreatmentsWithConfig = { test_split: { ...previousTreatment }, }; - const action = actionCreator(key, newTreatments); + const action = actionCreator(key, newTreatments, 1000); const reduxState = splitReducer(stateWithTreatments, action); // control assertion - treatment object was not replaced in the state @@ -155,7 +244,7 @@ describe('Split reducer', () => { ...initialState, isReady, isReadyFromCache, - lastUpdate: action.payload.timestamp || initialState.lastUpdate, + lastUpdate: action.type === 'ADD_TREATMENTS' ? initialState.lastUpdate : 1000, treatments: { test_split: { [key]: newTreatments.test_split, @@ -173,7 +262,7 @@ describe('Split reducer', () => { config: previousTreatment.config, }, }; - const action = actionCreator(key, newTreatments); + const action = actionCreator(key, newTreatments, 1000); const reduxState = splitReducer(stateWithTreatments, action); // control assertion - treatment object was replaced in the state @@ -185,7 +274,7 @@ describe('Split reducer', () => { ...initialState, isReady, isReadyFromCache, - lastUpdate: action.payload.timestamp || initialState.lastUpdate, + lastUpdate: action.type === 'ADD_TREATMENTS' ? initialState.lastUpdate : 1000, treatments: { test_split: { [key]: newTreatments.test_split, @@ -204,7 +293,7 @@ describe('Split reducer', () => { }, }; // const action = addTreatments(key, newTreatments); - const action = actionCreator(key, newTreatments); + const action = actionCreator(key, newTreatments, 1000); const reduxState = splitReducer(stateWithTreatments, action); // control assertion - treatment object was replaced in the state @@ -216,7 +305,7 @@ describe('Split reducer', () => { ...initialState, isReady, isReadyFromCache, - lastUpdate: action.payload.timestamp || initialState.lastUpdate, + lastUpdate: action.type === 'ADD_TREATMENTS' ? initialState.lastUpdate : 1000, treatments: { test_split: { [key]: newTreatments.test_split, diff --git a/src/__tests__/selectorsWithStatus.test.ts b/src/__tests__/selectorsWithStatus.test.ts index bf3a7d6..9ee844b 100644 --- a/src/__tests__/selectorsWithStatus.test.ts +++ b/src/__tests__/selectorsWithStatus.test.ts @@ -1,17 +1,13 @@ /** Mocks */ -import { SPLIT_1, SPLIT_2, STATE_READY, USER_1 } from './utils/storeState'; -import { mockSdk, Event } from './utils/mockBrowserSplitSdk'; +import { SPLIT_1, SPLIT_2, USER_1, USER_2, STATUS_INITIAL, STATE_INITIAL, STATE_READY, STATE_READY_USER_2 } from './utils/storeState'; +import { mockSdk } from './utils/mockBrowserSplitSdk'; jest.mock('@splitsoftware/splitio', () => { return { SplitFactory: mockSdk() }; }); - -import mockStore from './utils/mockStore'; -import { STATE_INITIAL, STATUS_INITIAL } from './utils/storeState'; -import { sdkBrowserConfig } from './utils/sdkConfigs'; -import { initSplitSdk, getTreatments, splitSdk } from '../asyncActions'; +import { initSplitSdk, splitSdk } from '../asyncActions'; /** Constants */ -import { ON, CONTROL, CONTROL_WITH_CONFIG, ERROR_SELECTOR_NO_SPLITSTATE } from '../constants'; +import { ON, OFF, CONTROL, ERROR_SELECTOR_NO_SPLITSTATE } from '../constants'; /** Test targets */ import { @@ -44,54 +40,55 @@ describe('selectTreatmentAndStatus & selectTreatmentWithConfigAndStatus', () => expect(errorSpy).not.toHaveBeenCalled(); }); - it('if getTreatments action was not dispatched for the provided feature flag and key, returns default treatment and client status', () => { - const store = mockStore(STATE_INITIAL); - store.dispatch(initSplitSdk({ config: sdkBrowserConfig })); - (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); + it('in client-side, there might be more than one client with its own status', () => { + initSplitSdk({ + config: { + core: { + authorizationKey: 'SDK KEY', + key: USER_1, + }, + } + }); - expect(selectTreatmentAndStatus(STATE_INITIAL.splitio, SPLIT_1)).toEqual({ - treatment: CONTROL, - // status of main client: - ...STATUS_INITIAL, isReady: true, isOperational: true, + // Main client is ready and has treatments + expect(selectTreatmentAndStatus(STATE_READY.splitio, SPLIT_1)).toEqual({ + treatment: ON, + ...STATUS_INITIAL, isReady: true, lastUpdate: STATE_READY.splitio.lastUpdate, }); - expect(selectTreatmentAndStatus(STATE_INITIAL.splitio, SPLIT_1, USER_1, 'some_value')).toEqual({ - treatment: 'some_value', - // USER_1 client has not been initialized yet: - ...STATUS_INITIAL, + // USER_1 is the main client + expect(selectTreatmentWithConfigAndStatus(STATE_READY.splitio, SPLIT_2, USER_1)).toEqual({ + treatment: { treatment: OFF, config: null }, + ...STATUS_INITIAL, isReady: true, lastUpdate: STATE_READY.splitio.lastUpdate, }); - store.dispatch(getTreatments({ key: USER_1, splitNames: [SPLIT_2] })); - (splitSdk.factory as any).client(USER_1).__emitter__.emit(Event.SDK_READY_FROM_CACHE); + // USER_2 client is not ready and has no treatments + expect(selectTreatmentAndStatus(STATE_READY.splitio, SPLIT_1, USER_2)).toEqual({ + treatment: CONTROL, + ...STATUS_INITIAL, + }); - expect(selectTreatmentWithConfigAndStatus(STATE_INITIAL.splitio, SPLIT_2, USER_1)).toEqual({ - treatment: CONTROL_WITH_CONFIG, - // status of shared client: - ...STATUS_INITIAL, isReadyFromCache: true, isOperational: true, + // USER_2 client is ready but has no treatments + expect(selectTreatmentAndStatus(STATE_READY_USER_2.splitio, SPLIT_2, USER_2)).toEqual({ + treatment: CONTROL, + ...STATUS_INITIAL, isReady: true, lastUpdate: STATE_READY_USER_2.splitio.status![USER_2].lastUpdate, }); expect(errorSpy).not.toHaveBeenCalled(); }); - it('happy path: returns the treatment value and status of the client', () => { - // The following actions result in STATE_READY state: - const store = mockStore(); - store.dispatch(initSplitSdk({ config: sdkBrowserConfig })); - (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); - (splitSdk.factory as any).client(USER_1).__emitter__.emit(Event.SDK_READY_FROM_CACHE); - store.dispatch(getTreatments({ splitNames: [SPLIT_1] })); - store.dispatch(getTreatments({ key: USER_1, splitNames: [SPLIT_2] })); + it('in server-side, there is a single client and so all user keys share the same status', () => { + splitSdk.isDetached = true; expect(selectTreatmentAndStatus(STATE_READY.splitio, SPLIT_1)).toEqual({ treatment: ON, - ...STATUS_INITIAL, - isReady: true, isOperational: true, + ...STATUS_INITIAL, isReady: true, lastUpdate: STATE_READY.splitio.lastUpdate }); - expect(selectTreatmentWithConfigAndStatus(STATE_READY.splitio, SPLIT_2, USER_1)).toEqual({ - treatment: STATE_READY.splitio.treatments[SPLIT_2][USER_1], - ...STATUS_INITIAL, - isReadyFromCache: true, isOperational: true, + // U + expect(selectTreatmentAndStatus(STATE_READY.splitio, SPLIT_1, USER_2)).toEqual({ + treatment: CONTROL, + ...STATUS_INITIAL, isReady: true, lastUpdate: STATE_READY.splitio.lastUpdate }); expect(errorSpy).not.toHaveBeenCalled(); diff --git a/src/__tests__/utils/mockBrowserSplitSdk.ts b/src/__tests__/utils/mockBrowserSplitSdk.ts index f6c696e..37c6107 100644 --- a/src/__tests__/utils/mockBrowserSplitSdk.ts +++ b/src/__tests__/utils/mockBrowserSplitSdk.ts @@ -30,16 +30,26 @@ export function mockSdk() { return jest.fn((config: SplitIO.IBrowserSettings, __updateModules?: (modules: { settings: { version: string } }) => void) => { + // ATM, isReadyFromCache is shared among clients + let isReadyFromCache = false; + function mockClient(key?: SplitIO.SplitKey) { // Readiness - let __isReady__: boolean | undefined; - let __isReadyFromCache__: boolean | undefined; - let __hasTimedout__: boolean | undefined; - let __isDestroyed__: boolean | undefined; + let isReady = false; + let hasTimedout = false; + let isDestroyed = false; + let lastUpdate = 0; + + function syncLastUpdate() { + const dateNow = Date.now(); + lastUpdate = dateNow > lastUpdate ? dateNow : lastUpdate + 1; + } + const __emitter__ = new EventEmitter(); - __emitter__.once(Event.SDK_READY, () => { __isReady__ = true; }); - __emitter__.once(Event.SDK_READY_FROM_CACHE, () => { __isReadyFromCache__ = true; }); - __emitter__.once(Event.SDK_READY_TIMED_OUT, () => { __hasTimedout__ = true; }); + __emitter__.once(Event.SDK_READY, () => { isReady = true; syncLastUpdate(); }); + __emitter__.once(Event.SDK_READY_FROM_CACHE, () => { isReadyFromCache = true; syncLastUpdate(); }); + __emitter__.once(Event.SDK_READY_TIMED_OUT, () => { hasTimedout = true; syncLastUpdate(); }); + __emitter__.on(Event.SDK_UPDATE, () => { syncLastUpdate(); }); // Client methods const track: jest.Mock = jest.fn((tt, et, v, p) => { @@ -77,19 +87,22 @@ export function mockSdk() { }); const ready: jest.Mock = jest.fn(() => { return promiseWrapper(new Promise((res, rej) => { - __isReady__ ? res() : __emitter__.on(Event.SDK_READY, res); - __hasTimedout__ ? rej() : __emitter__.on(Event.SDK_READY_TIMED_OUT, rej); + isReady ? res() : __emitter__.on(Event.SDK_READY, res); + hasTimedout ? rej() : __emitter__.on(Event.SDK_READY_TIMED_OUT, rej); }), () => { }); }); const __getStatus = () => ({ - isReady: __isReady__ || false, - isReadyFromCache: __isReadyFromCache__ || false, - hasTimedout: __hasTimedout__ || false, - isDestroyed: __isDestroyed__ || false, - isOperational: ((__isReady__ || __isReadyFromCache__) && !__isDestroyed__) || false, + isReady, + isReadyFromCache, + isTimedout: hasTimedout && !isReady, + hasTimedout, + isDestroyed, + isOperational: (isReady || isReadyFromCache) && !isDestroyed, + lastUpdate, }); const destroy: jest.Mock = jest.fn(() => { - __isDestroyed__ = true; + isDestroyed = true; + syncLastUpdate(); return new Promise((res) => { setTimeout(res, 100); }); }); @@ -105,7 +118,7 @@ export function mockSdk() { getAttributes, // EventEmitter exposed to trigger events manually __emitter__, - // Clients expose a `__getStatus` method, that is not considered part of the public API, to get client readiness status (isReady, isReadyFromCache, isOperational, hasTimedout, isDestroyed) + // Clients expose a `__getStatus` method, that is not considered part of the public API, to get client readiness status (isReady, isReadyFromCache, isTimedout, hasTimedout, isDestroyed, isOperational, lastUpdate) __getStatus, }); } diff --git a/src/__tests__/utils/mockNodeSplitSdk.ts b/src/__tests__/utils/mockNodeSplitSdk.ts index a5eee41..874c32a 100644 --- a/src/__tests__/utils/mockNodeSplitSdk.ts +++ b/src/__tests__/utils/mockNodeSplitSdk.ts @@ -10,12 +10,20 @@ export const Event = { function mockClient() { // Readiness - let __isReady__: boolean | undefined; - let __hasTimedout__: boolean | undefined; - let __isDestroyed__: boolean | undefined; + let isReady = false; + let hasTimedout = false; + let isDestroyed = false; + let lastUpdate = 0; + + function syncLastUpdate() { + const dateNow = Date.now(); + lastUpdate = dateNow > lastUpdate ? dateNow : lastUpdate + 1; + } + const __emitter__ = new EventEmitter(); - __emitter__.once(Event.SDK_READY, () => { __isReady__ = true; }); - __emitter__.once(Event.SDK_READY_TIMED_OUT, () => { __hasTimedout__ = true; }); + __emitter__.once(Event.SDK_READY, () => { isReady = true; syncLastUpdate(); }); + __emitter__.once(Event.SDK_READY_TIMED_OUT, () => { hasTimedout = true; syncLastUpdate(); }); + __emitter__.on(Event.SDK_UPDATE, () => { syncLastUpdate(); }); // Client methods const track: jest.Mock = jest.fn(() => { @@ -35,19 +43,22 @@ function mockClient() { }); const ready: jest.Mock = jest.fn(() => { return promiseWrapper(new Promise((res, rej) => { - __isReady__ ? res() : __emitter__.on(Event.SDK_READY, res); - __hasTimedout__ ? rej() : __emitter__.on(Event.SDK_READY_TIMED_OUT, rej); + isReady ? res() : __emitter__.on(Event.SDK_READY, res); + hasTimedout ? rej() : __emitter__.on(Event.SDK_READY_TIMED_OUT, rej); }), () => { }); }); const __getStatus = () => ({ - isReady: __isReady__ || false, + isReady, isReadyFromCache: false, - hasTimedout: __hasTimedout__ || false, - isDestroyed: __isDestroyed__ || false, - isOperational: (__isReady__ && !__isDestroyed__) || false, + isTimedout: hasTimedout && !isReady, + hasTimedout, + isDestroyed, + isOperational: isReady && !isDestroyed, + lastUpdate, }); const destroy: jest.Mock = jest.fn(() => { - __isDestroyed__ = true; + isDestroyed = true; + syncLastUpdate(); return new Promise((res) => { setTimeout(res, 100); }); }); @@ -60,7 +71,7 @@ function mockClient() { Event, // EventEmitter exposed to trigger events manually __emitter__, - // Clients expose a `__getStatus` method, that is not considered part of the public API, to get client readiness status (isReady, isReadyFromCache, isOperational, hasTimedout, isDestroyed) + // Clients expose a `__getStatus` method, that is not considered part of the public API, to get client readiness status (isReady, isReadyFromCache, isTimedout, hasTimedout, isDestroyed, isOperational, lastUpdate) __getStatus, }); } diff --git a/src/__tests__/utils/storeState.ts b/src/__tests__/utils/storeState.ts index b4af869..bbf1aa1 100644 --- a/src/__tests__/utils/storeState.ts +++ b/src/__tests__/utils/storeState.ts @@ -12,15 +12,15 @@ export const USER_INVALID = 'user_invalid'; export const STATUS_INITIAL = { isReady: false, isReadyFromCache: false, + isTimedout: false, hasTimedout: false, isDestroyed: false, + lastUpdate: 0, }; export const STATE_INITIAL: { splitio: ISplitState } = { splitio: { ...STATUS_INITIAL, - isTimedout: false, - lastUpdate: 0, treatments: { }, }, @@ -44,3 +44,19 @@ export const STATE_READY: { splitio: ISplitState } = { }, }, }; + +export const STATE_READY_USER_2: { splitio: ISplitState } = { + splitio: { + ...STATE_READY.splitio, + status: { + [USER_2]: { + isReady: true, + isReadyFromCache: false, + isTimedout: false, + hasTimedout: false, + isDestroyed: false, + lastUpdate: 1192838124, + }, + }, + }, +}; diff --git a/src/__tests__/utils/toBeWithinRange.ts b/src/__tests__/utils/toBeWithinRange.ts new file mode 100644 index 0000000..ebb5746 --- /dev/null +++ b/src/__tests__/utils/toBeWithinRange.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +/* eslint-disable @typescript-eslint/no-empty-interface */ + +// Custom matcher https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectextendmatchers +import { expect } from '@jest/globals'; + +expect.extend({ + toBeWithinRange(received: any, floor: number, ceiling: number) { + const pass = received >= floor && received <= ceiling; + if (pass) { + return { + message: () => + `expected ${received} not to be within range ${floor} - ${ceiling}`, + pass: true, + }; + } else { + return { + message: () => + `expected ${received} to be within range ${floor} - ${ceiling}`, + pass: false, + }; + } + }, +}); + +interface CustomMatchers { + toBeWithinRange(floor: number, ceiling: number): R; +} + +declare global { + namespace jest { + interface Expect extends CustomMatchers { } + interface Matchers extends CustomMatchers { } + interface InverseAsymmetricMatchers extends CustomMatchers { } + } +} diff --git a/src/actions.ts b/src/actions.ts index 0d5b076..0c47857 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -8,80 +8,88 @@ import { } from './constants'; import { matching } from './utils'; -export function splitReady() { +export function splitReady(timestamp: number, key?: SplitIO.SplitKey) { return { type: SPLIT_READY, payload: { - timestamp: Date.now(), + timestamp, + key: matching(key), }, }; } -export function splitReadyWithEvaluations(key: SplitIO.SplitKey, treatments: SplitIO.TreatmentsWithConfig) { +export function splitReadyWithEvaluations(key: SplitIO.SplitKey, treatments: SplitIO.TreatmentsWithConfig, timestamp: number, nonDefaultKey?: boolean) { return { type: SPLIT_READY_WITH_EVALUATIONS, payload: { - timestamp: Date.now(), + timestamp, key: matching(key), treatments, + nonDefaultKey, }, }; } -export function splitReadyFromCache() { +export function splitReadyFromCache(timestamp: number, key?: SplitIO.SplitKey) { return { type: SPLIT_READY_FROM_CACHE, payload: { - timestamp: Date.now(), + timestamp, + key: matching(key), }, }; } -export function splitReadyFromCacheWithEvaluations(key: SplitIO.SplitKey, treatments: SplitIO.TreatmentsWithConfig) { +export function splitReadyFromCacheWithEvaluations(key: SplitIO.SplitKey, treatments: SplitIO.TreatmentsWithConfig, timestamp: number, nonDefaultKey?: boolean) { return { type: SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS, payload: { - timestamp: Date.now(), + timestamp, key: matching(key), treatments, + nonDefaultKey, }, }; } -export function splitUpdate() { +export function splitUpdate(timestamp: number, key?: SplitIO.SplitKey) { return { type: SPLIT_UPDATE, payload: { - timestamp: Date.now(), + timestamp, + key: matching(key), }, }; } -export function splitUpdateWithEvaluations(key: SplitIO.SplitKey, treatments: SplitIO.TreatmentsWithConfig) { +export function splitUpdateWithEvaluations(key: SplitIO.SplitKey, treatments: SplitIO.TreatmentsWithConfig, timestamp: number, nonDefaultKey?: boolean) { return { type: SPLIT_UPDATE_WITH_EVALUATIONS, payload: { - timestamp: Date.now(), + timestamp, key: matching(key), treatments, + nonDefaultKey, }, }; } -export function splitTimedout() { +export function splitTimedout(timestamp: number, key?: SplitIO.SplitKey) { return { type: SPLIT_TIMEDOUT, payload: { - timestamp: Date.now(), + timestamp, + key: matching(key), }, }; } -export function splitDestroy() { +export function splitDestroy(timestamp: number, key?: SplitIO.SplitKey) { return { type: SPLIT_DESTROY, payload: { - timestamp: Date.now(), + timestamp, + key: matching(key), }, }; } diff --git a/src/asyncActions.ts b/src/asyncActions.ts index ba6651c..d2cff5e 100644 --- a/src/asyncActions.ts +++ b/src/asyncActions.ts @@ -4,7 +4,7 @@ import { Dispatch, Action } from 'redux'; import { IInitSplitSdkParams, IGetTreatmentsParams, IDestroySplitSdkParams, ISplitFactoryBuilder } from './types'; import { splitReady, splitReadyWithEvaluations, splitReadyFromCache, splitReadyFromCacheWithEvaluations, splitTimedout, splitUpdate, splitUpdateWithEvaluations, splitDestroy, addTreatments } from './actions'; import { VERSION, ERROR_GETT_NO_INITSPLITSDK, ERROR_DESTROY_NO_INITSPLITSDK, getControlTreatmentsWithConfig } from './constants'; -import { matching, __getStatus, validateGetTreatmentsParams } from './utils'; +import { matching, __getStatus, validateGetTreatmentsParams, isMainClient } from './utils'; /** * Internal object SplitSdk. This object should not be accessed or @@ -16,7 +16,7 @@ export interface ISplitSdk { splitio: ISplitFactoryBuilder; factory: SplitIO.ISDK; sharedClients: { [stringKey: string]: SplitIO.IClient }; - isDetached: boolean; // true: server-side, false: client-side (i.e., client with binded key) + isDetached: boolean; // true: server-side, false: client-side (i.e., client with bound key) dispatch: Dispatch; } @@ -66,16 +66,16 @@ export function initSplitSdk(params: IInitSplitSdkParams): (dispatch: Dispatch voi client.evalOnReady.push(params); } + // @TODO breaking: consider removing `evalOnReadyFromCache` config option, since `false` value has no effect on shared clients (they are ready from cache immediately) and on the main client if its ready from cache when `getTreatments` is called // If the SDK is not ready from cache and flag `evalOnReadyFromCache`, it stores the action to execute when ready from cache if (!status.isReadyFromCache && params.evalOnReadyFromCache) { client.evalOnReadyFromCache.push(params); @@ -156,7 +157,12 @@ export function getTreatments(params: IGetTreatmentsParams): Action | (() => voi if (status.isOperational) { // If the SDK is operational (i.e., it is ready or ready from cache), it evaluates and adds treatments to the store const treatments = __getTreatments(client, [params]); - return addTreatments(params.key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments); + + // Shared clients might be ready from cache immediately, so we need to dispatch a single action that updates treatments and `isReadyFromCache` status atomically + // @TODO handle this corner case by refactoring actions into a single action that includes both the client status and optional evaluation/s, to minimize state changes and avoid edge cases + return status.isReadyFromCache && !status.isReady && !isMainClient(params.key) ? + splitReadyFromCacheWithEvaluations(params.key, treatments, status.lastUpdate, true) : + addTreatments(params.key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments); } else { // Otherwise, it adds control treatments to the store, without calling the SDK (no impressions sent) // With flag sets, an empty object is passed since we don't know their feature flag names @@ -209,13 +215,12 @@ interface IClientNotDetached extends SplitIO.IClient { export function getClient(splitSdk: ISplitSdk, key?: SplitIO.SplitKey): IClientNotDetached { const stringKey = matching(key); - const isMainClient = !stringKey || stringKey === matching((splitSdk.config as SplitIO.IBrowserSettings).core.key); // we cannot simply use `stringKey` to get the client, since the main one could have been created with a bucketing key and/or a traffic type. - const client = (isMainClient ? splitSdk.factory.client() : splitSdk.factory.client(stringKey)) as IClientNotDetached; + const client = (isMainClient(key) ? splitSdk.factory.client() : splitSdk.factory.client(stringKey)) as IClientNotDetached; if (client._trackingStatus) return client; - if (!isMainClient) splitSdk.sharedClients[stringKey] = client; + if (!isMainClient(key)) splitSdk.sharedClients[stringKey] = client; client._trackingStatus = true; client.evalOnUpdate = {}; // getTreatment actions stored to execute on SDK update client.evalOnReady = []; // getTreatment actions stored to execute when the SDK is ready @@ -227,49 +232,48 @@ export function getClient(splitSdk: ISplitSdk, key?: SplitIO.SplitKey): IClientN client.once(client.Event.SDK_READY, function onReady() { if (!splitSdk.dispatch) return; - // @TODO dispatch `splitReady` and `splitReadyWithEvaluations` for shared clients eventually + const lastUpdate = __getStatus(client).lastUpdate; if (client.evalOnReady.length) { const treatments = __getTreatments(client, client.evalOnReady); - if (!key) splitSdk.dispatch(splitReadyWithEvaluations((splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments)); - else splitSdk.dispatch(addTreatments(key, treatments)); - - } else if (!key) splitSdk.dispatch(splitReady()); + splitSdk.dispatch(splitReadyWithEvaluations(key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments, lastUpdate, key && true)); + } else { + splitSdk.dispatch(splitReady(lastUpdate, key)); + } }); // On SDK timed out, dispatch `splitTimedout` action client.once(client.Event.SDK_READY_TIMED_OUT, function onTimedout() { - // @TODO dispatch for shared clients eventually - if (splitSdk.dispatch && !key) splitSdk.dispatch(splitTimedout()); + if (splitSdk.dispatch) splitSdk.dispatch(splitTimedout(__getStatus(client).lastUpdate, key)); }); - // On SDK timed out, dispatch `splitReadyFromCache` action + // On SDK ready from cache, dispatch `splitReadyFromCache` action client.once(client.Event.SDK_READY_FROM_CACHE, function onReadyFromCache() { if (!splitSdk.dispatch) return; - // @TODO dispatch `splitReadyFromCache` and `splitReadyFromCacheWithEvaluations` for shared clients eventually + const lastUpdate = __getStatus(client).lastUpdate; if (client.evalOnReadyFromCache.length) { const treatments = __getTreatments(client, client.evalOnReadyFromCache); - if (!key) splitSdk.dispatch(splitReadyFromCacheWithEvaluations((splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments)); - else splitSdk.dispatch(addTreatments(key, treatments)); - - } else if (!key) splitSdk.dispatch(splitReadyFromCache()); + splitSdk.dispatch(splitReadyFromCacheWithEvaluations(key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments, lastUpdate, key && true)); + } else { + splitSdk.dispatch(splitReadyFromCache(lastUpdate, key)); + } }); // On SDK update, evaluate the registered `getTreatments` actions and dispatch `splitUpdate` action client.on(client.Event.SDK_UPDATE, function onUpdate() { if (!splitSdk.dispatch) return; - // @TODO dispatch `splitUpdate` and `splitUpdateWithEvaluations` for shared clients eventually + const lastUpdate = __getStatus(client).lastUpdate; const evalOnUpdate = Object.values(client.evalOnUpdate); if (evalOnUpdate.length) { const treatments = __getTreatments(client, evalOnUpdate); - if (!key) splitSdk.dispatch(splitUpdateWithEvaluations((splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments)); - else splitSdk.dispatch(addTreatments(key, treatments)); - - } else if (!key) splitSdk.dispatch(splitUpdate()); + splitSdk.dispatch(splitUpdateWithEvaluations(key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments, lastUpdate, key && true)); + } else { + splitSdk.dispatch(splitUpdate(lastUpdate, key)); + } }); return client; @@ -307,7 +311,7 @@ export function destroySplitSdk(params: IDestroySplitSdkParams = {}): (dispatch: return (dispatch: Dispatch): Promise => { dispatched = true; return Promise.all(destroyPromises).then(function () { - dispatch(splitDestroy()); + dispatch(splitDestroy(__getStatus(mainClient).lastUpdate)); if (params.onDestroy) params.onDestroy(); }); }; diff --git a/src/helpers.ts b/src/helpers.ts index 1b35fcf..4b658ac 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,7 +1,8 @@ import { splitSdk, getClient } from './asyncActions'; import { IStatus, ITrackParams } from './types'; import { ERROR_TRACK_NO_INITSPLITSDK, ERROR_MANAGER_NO_INITSPLITSDK } from './constants'; -import { __getStatus, matching } from './utils'; +import { __getStatus, isMainClient, matching } from './utils'; +import { initialStatus } from './reducer'; /** * This function track events, i.e., it invokes the actual `client.track*` methods. @@ -86,12 +87,10 @@ export function getSplits(): SplitIO.SplitViews { } /** - * Gets an object with the status properties of the SDK client or manager: + * Gets an object with the status properties of the SDK client or manager. * - * - `isReady` indicates if the SDK client has emitted the SDK_READY event. - * - `isReadyFromCache` indicates if the SDK client has emitted the SDK_READY_FROM_CACHE event. - * - `hasTimedout` indicates if the SDK client has emitted the SDK_READY_TIMED_OUT event. - * - `isDestroyed` indicates if the SDK client has been destroyed, i.e., if the `destroySplitSdk` action was dispatched. + * This function is similar to the `selectStatus` selector, but it does not require the Split state as a parameter since it uses the global `splitSdk` object. + * Consider using the `selectStatus` selector instead for a more Redux-friendly approach. * * @param {SplitIO.SplitKey} key To use only on client-side. Ignored in server-side. If a key is provided and a client associated to that key has been used, the status of that client is returned. * If no key is provided, the status of the main client and manager is returned (the main client shares the status with the manager). @@ -103,17 +102,11 @@ export function getSplits(): SplitIO.SplitViews { export function getStatus(key?: SplitIO.SplitKey): IStatus { if (splitSdk.factory) { const stringKey = matching(key); - const isMainClient = splitSdk.isDetached || !stringKey || stringKey === matching((splitSdk.config as SplitIO.IBrowserSettings).core.key); - const client = isMainClient ? splitSdk.factory.client() : splitSdk.sharedClients[stringKey]; + const client = isMainClient(key) ? splitSdk.factory.client() : splitSdk.sharedClients[stringKey]; if (client) return __getStatus(client); } // Default status if SDK is not initialized or client is not found. No warning logs for now, in case the helper is used before actions are dispatched - return { - isReady: false, - isReadyFromCache: false, - hasTimedout: false, - isDestroyed: false, - }; + return { ...initialStatus }; } diff --git a/src/index.ts b/src/index.ts index c99eda8..0aefb73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,11 @@ export { splitReducer } from './reducer'; export { initSplitSdk, getTreatments, destroySplitSdk, splitSdk } from './asyncActions'; export { track, getSplitNames, getSplit, getSplits, getStatus } from './helpers'; -export { selectTreatmentValue, selectTreatmentWithConfig, selectTreatmentAndStatus, selectTreatmentWithConfigAndStatus } from './selectors'; +export { selectTreatmentValue, selectTreatmentWithConfig, selectTreatmentAndStatus, selectTreatmentWithConfigAndStatus, selectStatus } from './selectors'; // For React-redux export { connectSplit } from './react-redux/connectSplit'; export { connectToggler, mapTreatmentToProps, mapIsFeatureOnToProps } from './react-redux/connectToggler'; -export { ISplitState } from './types'; +// Types +export { IStatus, ISplitState, IGetSplitState, IInitSplitSdkParams, IGetTreatmentsParams, IDestroySplitSdkParams, ITrackParams } from './types'; diff --git a/src/react-redux/connectSplit.ts b/src/react-redux/connectSplit.ts index dd1dec6..b0d48e1 100644 --- a/src/react-redux/connectSplit.ts +++ b/src/react-redux/connectSplit.ts @@ -6,7 +6,7 @@ import { defaultGetSplitState } from '../selectors'; /** * This decorator connects your components with: * - The Split state at Redux, under the prop key `split`. - * - The action creator `getTreatments`, binded to the `dispatch` of your store. + * - The action creator `getTreatments`, bound to the `dispatch` of your store. * * @param {IGetSplitState} getSplitState optional function that takes the entire Redux state and returns * the state slice which corresponds to where the Split reducer was mounted. This functionality is rarely diff --git a/src/reducer.ts b/src/reducer.ts index 2b930de..b220288 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -1,45 +1,80 @@ import { Reducer } from 'redux'; -import { ISplitState } from './types'; +import { ISplitAction, ISplitState, IStatus } from './types'; import { SPLIT_READY, SPLIT_READY_WITH_EVALUATIONS, SPLIT_READY_FROM_CACHE, SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS, SPLIT_UPDATE, SPLIT_UPDATE_WITH_EVALUATIONS, SPLIT_TIMEDOUT, SPLIT_DESTROY, ADD_TREATMENTS, } from './constants'; -/** - * Initial default state for Split reducer - */ -const initialState: ISplitState = { +export const initialStatus = { isReady: false, isReadyFromCache: false, isTimedout: false, hasTimedout: false, isDestroyed: false, lastUpdate: 0, +} + +/** + * Initial default state for Split reducer + */ +const initialState: ISplitState = { + ...initialStatus, treatments: {}, }; -function setReady(state: ISplitState, timestamp: number) { - return { +function setStatus(state: ISplitState, patch: Partial, key?: string) { + return key ? { ...state, + status: { + ...state.status, + [key]: state.status && state.status[key] ? { + ...state.status[key], + ...patch, + } : { + ...initialStatus, + ...patch, + } + }, + } : { + ...state, + ...patch, + }; +} + +function setReady(state: ISplitState, timestamp: number, key?: string) { + return setStatus(state, { isReady: true, isTimedout: false, lastUpdate: timestamp, - }; + }, key); } -function setReadyFromCache(state: ISplitState, timestamp: number) { - return { - ...state, +function setReadyFromCache(state: ISplitState, timestamp: number, key?: string) { + return setStatus(state, { isReadyFromCache: true, lastUpdate: timestamp, - }; + }, key); } -function setUpdated(state: ISplitState, timestamp: number) { - return { - ...state, +function setTimedout(state: ISplitState, timestamp: number, key?: string) { + return setStatus(state, { + isTimedout: true, + hasTimedout: true, lastUpdate: timestamp, - }; + }, key); +} + +function setUpdated(state: ISplitState, timestamp: number, key?: string) { + return setStatus(state, { + lastUpdate: timestamp, + }, key); +} + +function setDestroyed(state: ISplitState, timestamp: number, key?: string) { + return setStatus(state, { + isDestroyed: true, + lastUpdate: timestamp, + }, key); } /** @@ -71,52 +106,41 @@ export const splitReducer: Reducer = function ( state = initialState, action, ) { - switch (action.type) { + const { type, payload: { timestamp, key, treatments, nonDefaultKey } = {} } = action as ISplitAction; + + switch (type) { case SPLIT_READY: - return setReady(state, (action as any).payload.timestamp); + return setReady(state, timestamp, key); case SPLIT_READY_FROM_CACHE: - return setReadyFromCache(state, (action as any).payload.timestamp); + return setReadyFromCache(state, timestamp, key); case SPLIT_TIMEDOUT: - return { - ...state, - isTimedout: true, - hasTimedout: true, - lastUpdate: (action as any).payload.timestamp, - }; + return setTimedout(state, timestamp, key); case SPLIT_UPDATE: - return setUpdated(state, (action as any).payload.timestamp); + return setUpdated(state, timestamp, key); case SPLIT_DESTROY: - return { - ...state, - isDestroyed: true, - lastUpdate: (action as any).payload.timestamp, - }; + return setDestroyed(state, timestamp, key); case ADD_TREATMENTS: { - const { key, treatments } = (action as any).payload; const result = { ...state }; return assignTreatments(result, key, treatments); } case SPLIT_READY_WITH_EVALUATIONS: { - const { key, treatments, timestamp } = (action as any).payload; - const result = setReady(state, timestamp); + const result = setReady(state, timestamp, nonDefaultKey && key); return assignTreatments(result, key, treatments); } case SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS: { - const { key, treatments, timestamp } = (action as any).payload; - const result = setReadyFromCache(state, timestamp); + const result = setReadyFromCache(state, timestamp, nonDefaultKey && key); return assignTreatments(result, key, treatments); } case SPLIT_UPDATE_WITH_EVALUATIONS: { - const { key, treatments, timestamp } = (action as any).payload; - const result = setUpdated(state, timestamp); + const result = setUpdated(state, timestamp, nonDefaultKey && key); return assignTreatments(result, key, treatments); } diff --git a/src/selectors.ts b/src/selectors.ts index c68064b..ed1411e 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -1,7 +1,7 @@ import { ISplitState, IStatus } from './types'; import { CONTROL, CONTROL_WITH_CONFIG, DEFAULT_SPLIT_STATE_SLICE, ERROR_SELECTOR_NO_SPLITSTATE } from './constants'; -import { matching } from './utils'; -import { getStatus } from './helpers'; +import { isMainClient, matching } from './utils'; +import { initialStatus } from './reducer'; export const getStateSlice = (sliceName: string) => (state: any) => state[sliceName]; @@ -76,10 +76,33 @@ export function selectTreatmentWithConfigAndStatus(splitState: ISplitState, feat } & IStatus { const treatment = selectTreatmentWithConfig(splitState, featureFlagName, key, defaultValue); - const status = getStatus(key); + const status = selectStatus(splitState, key); return { ...status, treatment, }; } + +/** + * Extracts an object with the status properties of the SDK client or manager from the Split state. + * + * @param {ISplitState} splitState + * @param {SplitIO.SplitKey} key To use only on client-side. Ignored in server-side. If a key is provided and a client associated to that key has been used, the status of that client is returned. + * If no key is provided, the status of the main client and manager is returned (the main client shares the status with the manager). + * + * @returns {IStatus} The status of the SDK client or manager. + * + * @see {@link https://help.split.io/hc/en-us/articles/360038851551-Redux-SDK#subscribe-to-events} + */ +export function selectStatus(splitState: ISplitState, key?: SplitIO.SplitKey): IStatus { + const status = splitState ? + isMainClient(key) ? + splitState : + splitState.status && splitState.status[matching(key)] : + console.error(ERROR_SELECTOR_NO_SPLITSTATE); + + return status ? + { isReady: status.isReady, isReadyFromCache: status.isReadyFromCache, isTimedout: status.isTimedout, hasTimedout: status.hasTimedout, isDestroyed: status.isDestroyed, lastUpdate: status.lastUpdate } : + { ...initialStatus }; +} diff --git a/src/types.ts b/src/types.ts index 6af78ec..4b2d6ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,7 @@ export interface IStatus { /** * isReady indicates if Split client is ready, i.e., if it has emitted an SDK_READY event. - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#advanced-subscribe-to-events-and-changes} + * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#subscribe-to-events} */ isReady: boolean; @@ -12,14 +12,21 @@ export interface IStatus { * isReadyFromCache indicates if Split client has emitted an SDK_READY_FROM_CACHE event, what means that the SDK is ready to * evaluate using LocalStorage cached data (which might be stale). * This flag only applies for the Browser if using LOCALSTORAGE as storage type. - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#advanced-subscribe-to-events-and-changes} + * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#subscribe-to-events} */ isReadyFromCache: boolean; + /** + * isTimedout indicates if the Split client has emitted an SDK_READY_TIMED_OUT event and is not ready. + * In other words, `isTimedout` is equivalent to `hasTimeout && !isReady`. + * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#subscribe-to-events} + */ + isTimedout: boolean; + /** * hasTimedout indicates if the Split client has ever emitted an SDK_READY_TIMED_OUT event. * It's meant to keep a reference that the SDK emitted a timeout at some point, not the current state. - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#advanced-subscribe-to-events-and-changes} + * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#subscribe-to-events} */ hasTimedout: boolean; @@ -28,23 +35,16 @@ export interface IStatus { * @see {@link https://help.split.io/hc/en-us/articles/360038851551-Redux-SDK#shutdown} */ isDestroyed: boolean; -} - -/** Type for Split reducer's slice of state */ -export interface ISplitState extends IStatus { - - /** - * isTimedout indicates if the Split client has emitted an SDK_READY_TIMED_OUT event and is not ready. - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#advanced-subscribe-to-events-and-changes} - */ - isTimedout: boolean; /** * lastUpdate is the timestamp of the last Split client event (SDK_READY, SDK_READY_TIMED_OUT or SDK_UPDATE). - * @see {@link https://help.split.io/hc/en-us/articles/360038851551-Redux-SDK#advanced-subscribe-to-events-and-changes} + * @see {@link https://help.split.io/hc/en-us/articles/360038851551-Redux-SDK#subscribe-to-events} */ lastUpdate: number; +} +/** Type for Split reducer's slice of state */ +export interface ISplitState extends IStatus { /** * `treatments` is a nested object property that contains the evaluations of feature flags. * Each evaluation (treatment) is associated with a feature flag name and a key (e.g., unique user identifier, such as a user id). @@ -55,6 +55,12 @@ export interface ISplitState extends IStatus { [key: string]: SplitIO.TreatmentWithConfig; }; }; + /** + * `status` is a nested object property that contains the readiness status of the non-default clients. + */ + status?: { + [key: string]: IStatus; + }; } export type IGetSplitState = (state: any) => ISplitState; @@ -189,3 +195,13 @@ export interface ITrackParams { } export type ISplitFactoryBuilder = (settings: SplitIO.IBrowserSettings | SplitIO.INodeSettings) => SplitIO.ISDK; + +export type ISplitAction = { + type: string; + payload: { + timestamp?: number; + key?: string; + treatments?: SplitIO.TreatmentsWithConfig; + nonDefaultKey?: boolean; + }; +} diff --git a/src/utils.ts b/src/utils.ts index bdf915d..3af94d3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,21 +1,42 @@ +import { splitSdk } from './asyncActions'; import { ERROR_GETT_NO_PARAM_OBJECT, WARN_FEATUREFLAGS_AND_FLAGSETS } from './constants'; import { IGetTreatmentsParams } from './types'; /** - * Validates if a value is an object. + * Validates if a given value is a plain object */ export function isObject(obj: unknown) { return obj && typeof obj === 'object' && obj.constructor === Object; } +/** + * Validates if a given value is a string + */ +function isString(val: unknown): val is string { + return typeof val === 'string' || val instanceof String; +} + +/** + * Removes duplicate items on an array of strings + */ +function uniq(arr: string[]): string[] { + const seen: Record = {}; + return arr.filter((item) => { + return Object.prototype.hasOwnProperty.call(seen, item) ? false : seen[item] = true; + }); +} + /** * Verify type of key and return either its matchingKey or itself */ -export function matching(key: SplitIO.SplitKey): string { - return isObject(key) ? (key as SplitIO.SplitKeyObject).matchingKey : (key as string); +export function matching(key?: SplitIO.SplitKey): string | undefined { + return isObject(key) ? (key as SplitIO.SplitKeyObject).matchingKey : (key as string | undefined); } -// The following utils might be removed in the future, if the JS SDK extends its public API with a "getStatus" method +export function isMainClient(key?: SplitIO.SplitKey) { + const stringKey = matching(key); + return splitSdk.isDetached || !stringKey || stringKey === matching(splitSdk.config && (splitSdk.config as SplitIO.IBrowserSettings).core.key); +} /** * ClientWithContext interface. @@ -23,11 +44,14 @@ export function matching(key: SplitIO.SplitKey): string { export interface IClientStatus { isReady: boolean; isReadyFromCache: boolean; - isOperational: boolean; + isTimedout: boolean; hasTimedout: boolean; isDestroyed: boolean; + isOperational: boolean; + lastUpdate: number; } +// The following util might be removed in the future, if the JS SDK extends its public API with a "getStatus" method export function __getStatus(client: SplitIO.IClient): IClientStatus { // @ts-expect-error, function exists but it is not part of JS SDK type definitions return client.__getStatus(); @@ -113,20 +137,3 @@ function validateFeatureFlag(maybeFeatureFlag: unknown, item = 'feature flag nam return false; } - -/** - * Removes duplicate items on an array of strings. - */ -function uniq(arr: string[]): string[] { - const seen: Record = {}; - return arr.filter((item) => { - return Object.prototype.hasOwnProperty.call(seen, item) ? false : seen[item] = true; - }); -} - -/** - * Checks if a given value is a string. - */ -function isString(val: unknown): val is string { - return typeof val === 'string' || val instanceof String; -}