diff --git a/CHANGES.txt b/CHANGES.txt index 4a33f6d..c021991 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ -1.6.0 (October XX, 2025) +1.6.0 (October 30, 2025) + - Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs. - Added `client.getStatus()` method to retrieve the client readiness status properties (`isReady`, `isReadyFromCache`, etc). - Added `client.whenReady()` and `client.whenReadyFromCache()` methods to replace the deprecated `client.ready()` method, which has an issue causing the returned promise to hang when using async/await syntax if it was rejected. - Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted. diff --git a/package-lock.json b/package-lock.json index fd053c6..aa1e9c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@splitsoftware/splitio-browserjs", - "version": "1.5.1", + "version": "1.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-browserjs", - "version": "1.5.1", + "version": "1.6.0", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio-commons": "2.7.9-rc.2", + "@splitsoftware/splitio-commons": "2.8.0", "tslib": "^2.3.1", "unfetch": "^4.2.0" }, @@ -1396,9 +1396,9 @@ "dev": true }, "node_modules/@splitsoftware/splitio-commons": { - "version": "2.7.9-rc.2", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.7.9-rc.2.tgz", - "integrity": "sha512-t8YVwDe4UBvD95w+mvKq7Z2khozZXDrIuOWt3ixxtmyeyoZp5L0L9x9E3DWOcQ0EVxfpQv+tAErHG3bw3LkbNg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.8.0.tgz", + "integrity": "sha512-QgHUreMOEDwf4GZzVPu4AzkZJvuaeSoHsiJc4tT3CxSIYl2bKMz1SSDlI1tW/oVbIFeWjkrIp2lCYEyUBgcvyA==", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", @@ -10493,9 +10493,9 @@ "dev": true }, "@splitsoftware/splitio-commons": { - "version": "2.7.9-rc.2", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.7.9-rc.2.tgz", - "integrity": "sha512-t8YVwDe4UBvD95w+mvKq7Z2khozZXDrIuOWt3ixxtmyeyoZp5L0L9x9E3DWOcQ0EVxfpQv+tAErHG3bw3LkbNg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.8.0.tgz", + "integrity": "sha512-QgHUreMOEDwf4GZzVPu4AzkZJvuaeSoHsiJc4tT3CxSIYl2bKMz1SSDlI1tW/oVbIFeWjkrIp2lCYEyUBgcvyA==", "requires": { "@types/ioredis": "^4.28.0", "tslib": "^2.3.1" diff --git a/package.json b/package.json index 7c20a04..c710d4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-browserjs", - "version": "1.5.1", + "version": "1.6.0", "description": "Split SDK for JavaScript on Browser", "main": "cjs/index.js", "module": "esm/index.js", @@ -59,7 +59,7 @@ "bugs": "https://github.com/splitio/javascript-browser-client/issues", "homepage": "https://github.com/splitio/javascript-browser-client#readme", "dependencies": { - "@splitsoftware/splitio-commons": "2.7.9-rc.2", + "@splitsoftware/splitio-commons": "2.8.0", "tslib": "^2.3.1", "unfetch": "^4.2.0" }, diff --git a/src/__tests__/browserSuites/evaluations-fallback.spec.js b/src/__tests__/browserSuites/evaluations-fallback.spec.js new file mode 100644 index 0000000..e18cad7 --- /dev/null +++ b/src/__tests__/browserSuites/evaluations-fallback.spec.js @@ -0,0 +1,317 @@ +import sinon from 'sinon'; +import { SplitFactory } from '../../'; + +const listener = { + logImpression: sinon.stub() +}; + +export default function (configInMemory, configInLocalStorage, fetchMock, assert) { + + assert.test('FallbackTreatment / Split factory with no fallbackTreatment defined', async t => { + + const splitio = SplitFactory(configInMemory); + const client = splitio.client(); + + await client.whenReady(); + + t.equal(client.getTreatment('non_existent_flag'), 'control', 'The evaluation will return `control` if the flag does not exist and no fallbackTreatment is defined'); + t.equal(client.getTreatment('non_existent_flag_2'), 'control', 'The evaluation will return `control` if the flag does not exist and no fallbackTreatment is defined'); + + await client.destroy(); + t.end(); + + }); + + assert.test('FallbackTreatment / Split factory with global fallbackTreatment defined', async t => { + + const config = { + ...configInMemory, + fallbackTreatments: { + global: 'FALLBACK_TREATMENT' + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + + t.equal(client.getTreatment('non_existent_flag'), 'FALLBACK_TREATMENT', 'The evaluation will return `FALLBACK_TREATMENT` if the flag does not exist and no fallbackTreatment is defined'); + t.equal(client.getTreatment('non_existent_flag_2'), 'FALLBACK_TREATMENT', 'The evaluation will return `FALLBACK_TREATMENT` if the flag does not exist and no fallbackTreatment is defined'); + + await client.destroy(); + t.end(); + + }); + + assert.test('FallbackTreatment / Split factory with specific fallbackTreatment defined', async t => { + + const config = { + ...configInMemory, + fallbackTreatments: { + byFlag: { + 'non_existent_flag': 'FALLBACK_TREATMENT', + } + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + t.equal(client.getTreatment('non_existent_flag'), 'FALLBACK_TREATMENT', 'The evaluation will return `FALLBACK_TREATMENT` if the flag does not exist and no fallbackTreatment is defined'); + t.equal(client.getTreatment('non_existent_flag_2'), 'control', 'The evaluation will return `control` if the flag does not exist and no fallbackTreatment is defined'); + + t.equal(client.getTreatment('non_existent_flag'), 'FALLBACK_TREATMENT', 'The evaluation will return `FALLBACK_TREATMENT` if the flag does not exist and no fallbackTreatment is defined'); + t.equal(client.getTreatment('non_existent_flag_2'), 'control', 'The evaluation will return `control` if the flag does not exist and no fallbackTreatment is defined'); + + await client.destroy(); + t.end(); + + }); + + + assert.test('FallbackTreatment / flag override beats global fallbackTreatment', async t => { + + const config = { + ...configInMemory, + fallbackTreatments: { + global: 'OFF_FALLBACK', + byFlag: { + 'my_flag': 'ON_FALLBACK', + } + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + t.equal(client.getTreatment('my_flag'), 'ON_FALLBACK', 'The evaluation will return `ON_FALLBACK` if the flag does not exist and no fallbackTreatment is defined'); + t.equal(client.getTreatment('non_existent_flag_2'), 'OFF_FALLBACK', 'The evaluation will return `OFF_FALLBACK` if the flag does not exist and no fallbackTreatment is defined'); + + t.equal(client.getTreatment('my_flag'), 'ON_FALLBACK', 'The evaluation will return `ON_FALLBACK` if the flag does not exist and no fallbackTreatment is defined'); + t.equal(client.getTreatment('non_existent_flag_2'), 'OFF_FALLBACK', 'The evaluation will return `OFF_FALLBACK` if the flag does not exist and no fallbackTreatment is defined'); + + await client.destroy(); + t.end(); + + }); + + assert.test('FallbackTreatment / override applies only when original is control', async t => { + + const config = { + ...configInMemory, + fallbackTreatments: { + global: 'OFF_FALLBACK' + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + t.equal(client.getTreatment('user_account_in_whitelist'), 'off', 'The evaluation will return the treatment defined in the flag if it exists'); + t.equal(client.getTreatment('non_existent_flag'), 'OFF_FALLBACK', 'The evaluation will return `OFF_FALLBACK` if the flag does not exist and no fallbackTreatment is defined'); + + await client.destroy(); + t.end(); + + }); + + + assert.test('FallbackTreatment / override applies only when original is control - inLocalStorage', async t => { + + const config = { + ...configInLocalStorage, + fallbackTreatments: { + global: 'OFF_FALLBACK' + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + t.equal(client.getTreatment('user_account_in_whitelist'), 'off', 'The evaluation will return the treatment defined in the flag if it exists'); + t.equal(client.getTreatment('non_existent_flag'), 'OFF_FALLBACK', 'The evaluation will return `OFF_FALLBACK` if the flag does not exist and no fallbackTreatment is defined'); + + await client.destroy(); + t.end(); + + }); + + assert.test('FallbackTreatment / Impressions correctness with fallback when client is not ready', async t => { + + const config = { + ...configInMemory, + urls: { + events: 'https://events.fallbacktreatment/api' + }, + fallbackTreatments: { + byFlag: { + 'any_flag': 'OFF_FALLBACK' + } + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + t.equal(client.getTreatment('any_flag'), 'OFF_FALLBACK', 'The evaluation will return the fallbackTreatment if the client is not ready yet'); + t.equal(client.getTreatment('user_account_in_whitelist'), 'control', 'The evaluation will return the fallbackTreatment if the client is not ready yet'); + + await client.whenReady(); + + fetchMock.postOnce(config.urls.events + '/testImpressions/bulk', (_, opts) => { + + const payload = JSON.parse(opts.body); + + function validateImpressionData(featureFlagName, expectedLabel) { + const impressions = payload.find(e => e.f === featureFlagName).i; + + t.equal(impressions[0].r, expectedLabel, `${featureFlagName} impression with label ${expectedLabel}`); + } + + validateImpressionData('any_flag', 'fallback - not ready'); + validateImpressionData('user_account_in_whitelist', 'not ready'); + t.end(); + + return 200; + }); + + await client.destroy(); + + }); + + assert.test('FallbackTreatment / Fallback dynamic config propagation', async t => { + + const config = { + ...configInMemory, + fallbackTreatments: { + global: { treatment: 'OFF_FALLBACK', config: '{"global": true}' }, + byFlag: { + 'my_flag': { treatment: 'ON_FALLBACK', config: '{"flag": true}' } + } + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + t.deepEqual(client.getTreatmentWithConfig('my_flag'), { treatment: 'ON_FALLBACK', config: '{"flag": true}' }, 'The evaluation will propagate the config along with the treatment from the fallbackTreatment'); + t.deepEqual(client.getTreatmentWithConfig('non_existent_flag'), { treatment: 'OFF_FALLBACK', config: '{"global": true}' }, 'The evaluation will propagate the config along with the treatment from the fallbackTreatment'); + + await client.destroy(); + t.end(); + + }); + + assert.test('FallbackTreatment / Fallback dynamic config propagation - inLocalStorage', async t => { + + const config = { + ...configInLocalStorage, + fallbackTreatments: { + global: { treatment: 'OFF_FALLBACK', config: '{"global": true}' }, + byFlag: { + 'my_flag': { treatment: 'ON_FALLBACK', config: '{"flag": true}' } + } + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + t.deepEqual(client.getTreatmentWithConfig('my_flag'), { treatment: 'ON_FALLBACK', config: '{"flag": true}' }, 'The evaluation will propagate the config along with the treatment from the fallbackTreatment'); + t.deepEqual(client.getTreatmentWithConfig('non_existent_flag'), { treatment: 'OFF_FALLBACK', config: '{"global": true}' }, 'The evaluation will propagate the config along with the treatment from the fallbackTreatment'); + + await client.destroy(); + t.end(); + + }); + + assert.test('FallbackTreatment / Evaluations non existing flags with fallback do not generate impressions', async t => { + + const config = { + ...configInMemory, + urls: { + events: 'https://events.fallbacktreatment/api' + }, + fallbackTreatments: { + global: { treatment: 'OFF_FALLBACK', config: '{"global": true}' }, + byFlag: { + 'my_flag': { treatment: 'ON_FALLBACK', config: '{"flag": true}' } + } + } + }; + config.impressionListener = listener; + + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + t.deepEqual(client.getTreatmentWithConfig('my_flag'), { treatment: 'ON_FALLBACK', config: '{"flag": true}' }, 'The evaluation will propagate the config along with the treatment from the fallbackTreatment'); + t.deepEqual(client.getTreatmentWithConfig('non_existent_flag'), { treatment: 'OFF_FALLBACK', config: '{"global": true}' }, 'The evaluation will propagate the config along with the treatment from the fallbackTreatment'); + + let POSTED_IMPRESSIONS_COUNT = 0; + + fetchMock.postOnce(config.urls.events + '/testImpressions/bulk', (_, opts) => { + + const payload = JSON.parse(opts.body); + t.equal(payload.length, 1, 'We should have just one impression for the two evaluated flags'); + + function validateImpressionData(featureFlagName, expectedLength) { + + const impressions = payload.find(e => e.f === featureFlagName).i; + t.equal(impressions.length, expectedLength, `${featureFlagName} has ${expectedLength} impressions`); + } + + validateImpressionData('my_flag', 1); + validateImpressionData('non_existent_flag', 0); + POSTED_IMPRESSIONS_COUNT = payload.reduce((acc, curr) => acc + curr.i.length, 0); + t.equal(POSTED_IMPRESSIONS_COUNT, 1, 'We should have just one impression in total.'); + + return 200; + }); + + setTimeout(() => { + t.equal(listener.logImpression.callCount, POSTED_IMPRESSIONS_COUNT, 'Impression listener should be called once per each impression generated.'); + + t.end(); + }, 0); + await client.destroy(); + + + }); + + assert.test('FallbackTreatment / LocalhostMode', async t => { + + const config = { + ...configInMemory, + core: { + ...configInMemory.core, + authorizationKey: 'localhost', + }, + fallbackTreatments: { + global: 'OFF_FALLBACK' + }, + features: { + testing_split: 'on', + } + }; + const splitio = SplitFactory(config); + const client = splitio.client(); + + await client.whenReady(); + + t.deepEqual(client.getTreatment('testing_split'), 'on', 'The evaluation should return the treatment defined in localhost mode'); + t.deepEqual(client.getTreatment('non_existent_flag'), 'OFF_FALLBACK', 'The evaluation will return `OFF_FALLBACK` if the flag does not exist'); + + await client.destroy(); + + t.end(); + }); + +} diff --git a/src/__tests__/online/browser.spec.js b/src/__tests__/online/browser.spec.js index a844787..42870e2 100644 --- a/src/__tests__/online/browser.spec.js +++ b/src/__tests__/online/browser.spec.js @@ -28,6 +28,7 @@ import membershipsNicolas from '../mocks/memberships.nicolas@split.io.json'; import membershipsMarcio from '../mocks/memberships.marcio@split.io.json'; import membershipsEmmanuel from '../mocks/memberships.emmanuel@split.io.json'; import { InLocalStorage } from '../../index'; +import evaluationsFallbackSuite from '../browserSuites/evaluations-fallback.spec'; const settings = settingsFactory({ core: { @@ -97,6 +98,7 @@ tape('## E2E CI Tests ##', function (assert) { /* Check client evaluations. */ assert.test('E2E / In Memory', evaluationsSuite.bind(null, configInMemory, fetchMock)); + assert.test('E2E / In Memory Fallback', evaluationsFallbackSuite.bind(null, configInMemory, configInLocalStorage, fetchMock)); assert.test('E2E / In Memory with Bucketing Key', evaluationsSuite.bind(null, configInMemoryWithBucketingKey, fetchMock)); assert.test('E2E / In LocalStorage with In Memory Fallback', evaluationsSuite.bind(null, configInLocalStorage, fetchMock)); /* Check impressions */ diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 35fdff3..199eb8d 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -2,7 +2,7 @@ import type SplitIO from '@splitsoftware/splitio-commons/types/splitio'; import { LogLevels, isLogLevelString } from '@splitsoftware/splitio-commons/src/logger/index'; import { CONSENT_GRANTED } from '@splitsoftware/splitio-commons/src/utils/constants'; -const packageVersion = '1.5.1'; +const packageVersion = '1.6.0'; /** * In browser, the default debug level, can be set via the `localStorage.splitio_debug` item. diff --git a/ts-tests/index.ts b/ts-tests/index.ts index b7d27e0..bc6feba 100644 --- a/ts-tests/index.ts +++ b/ts-tests/index.ts @@ -612,7 +612,14 @@ let fullBrowserSettings: SplitIO.IClientSideSettings = { getHeaderOverrides(context) { return { ...context.headers, 'header': 'value' }; }, } }, - userConsent: 'GRANTED' + userConsent: 'GRANTED', + fallbackTreatments: { + global: { treatment: 'global-treatment', config: '{"global": true}' }, + byFlag: { + 'my_flag': { treatment: 'flag-treatment', config: '{"flag": true}' }, + 'my_other_flag': 'other-flag-treatment' + } + } }; fullBrowserSettings.userConsent = 'DECLINED'; fullBrowserSettings.userConsent = 'UNKNOWN'; @@ -657,7 +664,14 @@ let fullBrowserAsyncSettings: SplitIO.IClientSideAsyncSettings = { getHeaderOverrides(context) { return { ...context.headers, 'header': 'value' }; }, } }, - userConsent: 'GRANTED' + userConsent: 'GRANTED', + fallbackTreatments: { + global: 'global-treatment', + byFlag: { + 'my_flag': { treatment: 'flag-treatment', config: '{"flag": true}' }, + 'my_other_flag': 'other-flag-treatment' + } + } }; fullBrowserAsyncSettings.mode = 'consumer_partial'; fullBrowserAsyncSettings.userConsent = 'DECLINED';