From a1a8b6052f08e48f1a0b37526e5e430f9ac74d33 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Wed, 10 Sep 2025 18:34:54 -0300 Subject: [PATCH 1/2] [FME-10028] support async clients and add events --- .gitignore | 1 + package-lock.json | 24 ++++ package.json | 1 + src/__tests__/mocks/redis-commands.txt | 11 ++ src/__tests__/nodeSuites/client.spec.js | 4 +- src/__tests__/nodeSuites/client_redis.spec.js | 115 ++++++++++++++++++ src/__tests__/nodeSuites/provider.spec.js | 4 +- src/__tests__/testUtils/index.js | 38 ++++-- src/lib/js-split-provider.ts | 38 ++++-- 9 files changed, 213 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/mocks/redis-commands.txt create mode 100644 src/__tests__/nodeSuites/client_redis.spec.js diff --git a/.gitignore b/.gitignore index af21268..530ebe7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* +dump.rdb # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/package-lock.json b/package-lock.json index 2985a6d..6f2e245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "globals": "^16.3.0", "jest": "^29.7.0", "jiti": "^2.5.1", + "redis-server": "^1.2.2", "replace": "^1.2.1", "rimraf": "^3.0.2", "ts-jest": "^29.4.1", @@ -6194,6 +6195,16 @@ "dev": true, "license": "MIT" }, + "node_modules/promise-queue": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/promise-queue/-/promise-queue-2.2.5.tgz", + "integrity": "sha512-p/iXrPSVfnqPft24ZdNNLECw/UrtLTpT3jpAAMzl/o5/rDsGCPo3/CQS2611flL6LkoEJ3oQZw7C8Q80ZISXRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6299,6 +6310,19 @@ "node": ">=4" } }, + "node_modules/redis-server": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/redis-server/-/redis-server-1.2.2.tgz", + "integrity": "sha512-pOaSIeSMVFkEFIuaMtpQ3TOr3uI4sUmEHm4ofGks5vTPRseHUszxyIlC70IFjUR9qSeH8o/ARZEM8dqcJmgGJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "promise-queue": "^2.2.5" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", diff --git a/package.json b/package.json index 2470b09..c3b12dc 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "globals": "^16.3.0", "jest": "^29.7.0", "jiti": "^2.5.1", + "redis-server": "^1.2.2", "replace": "^1.2.1", "rimraf": "^3.0.2", "ts-jest": "^29.4.1", diff --git a/src/__tests__/mocks/redis-commands.txt b/src/__tests__/mocks/redis-commands.txt new file mode 100644 index 0000000..2290645 --- /dev/null +++ b/src/__tests__/mocks/redis-commands.txt @@ -0,0 +1,11 @@ +FLUSHDB +DEL 'REDIS_NODE_UT.SPLITIO.segment.UT_SEGMENT' +SADD 'REDIS_NODE_UT.SPLITIO.segment.UT_SEGMENT' UT_Segment_member +SET 'REDIS_NODE_UT.SPLITIO.segment.UT_SEGMENT.till' 1492721958710 +SET 'REDIS_NODE_UT.SPLITIO.split.UT_IN_SEGMENT' '{"changeNumber":1492722104980,"trafficTypeName":"machine","name":"UT_IN_SEGMENT","seed":-202209840,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"","attribute":""},"matcherType":"IN_SEGMENT","negate":false,"userDefinedSegmentMatcherData":{"segmentName":"UT_SEGMENT"},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100}],"label":"whitelisted segment"},{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"machine","attribute":""},"matcherType":"ALL_KEYS","negate":false,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":0},{"treatment":"off","size":100}],"label":"in segment all"}]}' +SET 'REDIS_NODE_UT.SPLITIO.split.UT_NOT_IN_SEGMENT' '{"changeNumber":1492722747908,"trafficTypeName":"machine","name":"UT_NOT_IN_SEGMENT","seed":-56653132,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"machine","attribute":""},"matcherType":"IN_SEGMENT","negate":true,"userDefinedSegmentMatcherData":{"segmentName":"UT_SEGMENT"},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100},{"treatment":"off","size":0}],"label":"not in segment UT_SEGMENT"}]}' +SET 'REDIS_NODE_UT.SPLITIO.split.UT_NOT_SET_MATCHER' '{"changeNumber":1492723024413,"trafficTypeName":"machine","name":"UT_NOT_SET_MATCHER","seed":-93553840,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"machine","attribute":"permissions"},"matcherType":"CONTAINS_ANY_OF_SET","negate":true,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":["create","delete","update"]},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100},{"treatment":"off","size":0}],"label":"permissions does not contain any of [create, delete, ...]"}]}' +SET 'REDIS_NODE_UT.SPLITIO.split.UT_SET_MATCHER' '{"changeNumber":1492722926004,"trafficTypeName":"machine","name":"UT_SET_MATCHER","seed":-1995997836,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"machine","attribute":"permissions"},"matcherType":"CONTAINS_ANY_OF_SET","negate":false,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":["admin","premium","idol"]},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100},{"treatment":"off","size":0}],"label":"permissions contains any of [admin, premium, ...]"}]}' +SET 'REDIS_NODE_UT.SPLITIO.split.always-on' '{"changeNumber":1487277320548,"trafficTypeName":"user","name":"always-on","seed":1684183541,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"user","attribute":""},"matcherType":"ALL_KEYS","negate":false,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"on","size":100},{"treatment":"off","size":0}],"label":"in segment all"}]}' +SET 'REDIS_NODE_UT.SPLITIO.split.always-o.n-with-config' '{"changeNumber":1487277320548,"trafficTypeName":"user","name":"always-o.n-with-config","seed":1684183541,"status":"ACTIVE","killed":false,"defaultTreatment":"off","conditions":[{"matcherGroup":{"combiner":"AND","matchers":[{"keySelector":{"trafficType":"user","attribute":""},"matcherType":"ALL_KEYS","negate":false,"userDefinedSegmentMatcherData":{"segmentName":""},"unaryNumericMatcherData":{"dataType":"","value":0},"whitelistMatcherData":{"whitelist":null},"betweenMatcherData":{"dataType":"","start":0,"end":0}}]},"partitions":[{"treatment":"o.n","size":100},{"treatment":"off","size":0}],"label":"in segment all"}],"configurations":{"o.n":"{\"color\":\"brown\"}"}}' +SET 'REDIS_NODE_UT.SPLITIO.splits.till' 1492723024413 diff --git a/src/__tests__/nodeSuites/client.spec.js b/src/__tests__/nodeSuites/client.spec.js index 7bb0cc6..e2faccc 100644 --- a/src/__tests__/nodeSuites/client.spec.js +++ b/src/__tests__/nodeSuites/client.spec.js @@ -1,6 +1,6 @@ /* eslint-disable jest/no-conditional-expect */ import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; -import { getSplitClient } from '../testUtils'; +import { getLocalHostSplitClient } from '../testUtils'; import { OpenFeature } from '@openfeature/server-sdk'; @@ -11,7 +11,7 @@ describe('client tests', () => { let provider; beforeEach(() => { - splitClient = getSplitClient(); + splitClient = getLocalHostSplitClient(); provider = new OpenFeatureSplitProvider({ splitClient }); OpenFeature.setProvider(provider); diff --git a/src/__tests__/nodeSuites/client_redis.spec.js b/src/__tests__/nodeSuites/client_redis.spec.js new file mode 100644 index 0000000..6496596 --- /dev/null +++ b/src/__tests__/nodeSuites/client_redis.spec.js @@ -0,0 +1,115 @@ +import RedisServer from 'redis-server'; +import { exec } from 'child_process'; +import { OpenFeature } from '@openfeature/server-sdk'; + +import { getRedisSplitClient } from '../testUtils'; +import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; + +const redisPort = '6385'; + +/** + * Initialize redis server and run a cli bash command to load redis with data to do the proper tests + */ +const startRedis = () => { + // Simply pass the port that you want a Redis server to listen on. + const server = new RedisServer(redisPort); + + const promise = new Promise((resolve, reject) => { + server + .open() + .then(() => { + exec(`cat ./src/__tests__/mocks/redis-commands.txt | redis-cli -p ${redisPort}`, err => { + if (err) { + reject(server); + // Node.js couldn't execute the command + return; + } + resolve(server); + }); + }); + }); + + return promise; +}; + +let redisServer +let splitClient + +beforeAll(async () => { + redisServer = await startRedis(); +}, 30000); + +afterAll(async () => { + await redisServer.close(); + await splitClient.destroy(); +}); + +describe('Regular usage - DEBUG strategy', () => { + splitClient = getRedisSplitClient(redisPort); + const provider = new OpenFeatureSplitProvider({ splitClient }); + + OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + + test('Evaluate always on flag', async () => { + await client.getBooleanValue('always-on', false, {targetingKey: 'emma-ss'}).then(result => { + expect(result).toBe(true); + }); + }); + + test('Evaluate user in segment', async () => { + await client.getBooleanValue('UT_IN_SEGMENT', false, {targetingKey: 'UT_Segment_member', properties: { /* empty properties are ignored */ }}).then(result => { + expect(result).toBe(true); + }); + + await client.getBooleanValue('UT_IN_SEGMENT', false, {targetingKey: 'UT_Segment_member', properties: { some: 'value1' } }).then(result => { + expect(result).toBe(true); + }); + + await client.getBooleanValue('UT_IN_SEGMENT', false, {targetingKey: 'other' }).then(result => { + expect(result).toBe(false); + }); + + await client.getBooleanValue('UT_NOT_IN_SEGMENT', true, {targetingKey: 'UT_Segment_member' }).then(result => { + expect(result).toBe(false); + }); + + await client.getBooleanValue('UT_NOT_IN_SEGMENT', true, {targetingKey: 'other' }).then(result => { + expect(result).toBe(true); + }); + + await client.getBooleanValue('UT_NOT_IN_SEGMENT', true, {targetingKey: 'other' }).then(result => { + expect(result).toBe(true); + }); + }); + + test('Evaluate with attributes set matcher', async () => { + await client.getBooleanValue('UT_SET_MATCHER', false, {targetingKey: 'UT_Segment_member', permissions: ['admin'] }).then(result => { + expect(result).toBe(true); + }); + + await client.getBooleanValue('UT_SET_MATCHER', false, {targetingKey: 'UT_Segment_member', permissions: ['not_matching'] }).then(result => { + expect(result).toBe(false); + }); + + await client.getBooleanValue('UT_NOT_SET_MATCHER', true, {targetingKey: 'UT_Segment_member', permissions: ['create'] }).then(result => { + expect(result).toBe(false); + }); + + await client.getBooleanValue('UT_NOT_SET_MATCHER', true, {targetingKey: 'UT_Segment_member', permissions: ['not_matching'] }).then(result => { + expect(result).toBe(true); + }); + }) + + test('Evaluate with dynamic config', async () => { + await client.getBooleanDetails('UT_NOT_SET_MATCHER', true, {targetingKey: 'UT_Segment_member', permissions: ['not_matching'] }).then(result => { + expect(result.value).toBe(true); + expect(result.flagMetadata).toEqual({'config': ''}); + }); + + await client.getStringDetails('always-o.n-with-config', 'control', {targetingKey: 'other'}).then(result => { + expect(result.value).toBe('o.n'); + expect(result.flagMetadata).toEqual({config: '{"color":"brown"}'}); + }); + }) +}); diff --git a/src/__tests__/nodeSuites/provider.spec.js b/src/__tests__/nodeSuites/provider.spec.js index a72875e..abc1aa8 100644 --- a/src/__tests__/nodeSuites/provider.spec.js +++ b/src/__tests__/nodeSuites/provider.spec.js @@ -1,5 +1,5 @@ /* eslint-disable jest/no-conditional-expect */ -import { getSplitClient } from '../testUtils'; +import { getLocalHostSplitClient } from '../testUtils'; import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; describe('provider tests', () => { @@ -8,7 +8,7 @@ describe('provider tests', () => { let provider; beforeEach(() => { - splitClient = getSplitClient(); + splitClient = getLocalHostSplitClient(); provider = new OpenFeatureSplitProvider({ splitClient }); }); diff --git a/src/__tests__/testUtils/index.js b/src/__tests__/testUtils/index.js index 0d86c6a..2f71380 100644 --- a/src/__tests__/testUtils/index.js +++ b/src/__tests__/testUtils/index.js @@ -70,16 +70,40 @@ export function url(settings, target) { return `${settings.urls.sdk}${target}`; } +const getRedisConfig = (redisPort) => ({ + core: { + authorizationKey: 'SOME SDK KEY' // in consumer mode, SDK key is only used to track and log warning regarding duplicated SDK instances + }, + mode: 'consumer', + storage: { + type: 'REDIS', + prefix: 'REDIS_NODE_UT', + options: { + url: `redis://localhost:${redisPort}/0` + } + }, + sync: { + impressionsMode: 'DEBUG' + }, + startup: { + readyTimeout: 36000 // 10hs + } + }); -/** - * get a Split client in localhost mode for testing purposes - */ -export function getSplitClient(apiKey = 'localhost') { - return SplitFactory({ +const config = { core: { - authorizationKey: apiKey + authorizationKey: 'localhost' }, features: './split.yaml', debug: 'DEBUG' - }).client(); + } +/** + * get a Split client in localhost mode for testing purposes + */ +export function getLocalHostSplitClient() { + return SplitFactory(config).client(); } + +export function getRedisSplitClient(redisPort) { + return SplitFactory(getRedisConfig(redisPort)).client(); +} \ No newline at end of file diff --git a/src/lib/js-split-provider.ts b/src/lib/js-split-provider.ts index 82e71db..9f9455c 100644 --- a/src/lib/js-split-provider.ts +++ b/src/lib/js-split-provider.ts @@ -1,19 +1,22 @@ import { EvaluationContext, - Provider, - ResolutionDetails, - ParseError, FlagNotFoundError, + InvalidContextError, JsonValue, - TargetingKeyMissingError, + OpenFeatureEventEmitter, + ParseError, + Provider, + ProviderEvents, + ResolutionDetails, StandardResolutionReasons, - TrackingEventDetails, - InvalidContextError, + TargetingKeyMissingError, + TrackingEventDetails } from '@openfeature/server-sdk'; +import { SplitFactory } from '@splitsoftware/splitio'; import type SplitIO from '@splitsoftware/splitio/types/splitio'; -export interface SplitProviderOptions { - splitClient: SplitIO.IClient; +type SplitProviderOptions = { + splitClient: SplitIO.IClient | SplitIO.IAsyncClient; } type Consumer = { @@ -29,10 +32,21 @@ export class OpenFeatureSplitProvider implements Provider { name: 'split', }; private initialized: Promise; - private client: SplitIO.IClient; + private client: SplitIO.IClient | SplitIO.IAsyncClient; + + public readonly events = new OpenFeatureEventEmitter(); - constructor(options: SplitProviderOptions) { - this.client = options.splitClient; + constructor(options: SplitProviderOptions | string) { + + if (typeof(options) === 'string') { + const splitFactory = SplitFactory({core: { authorizationKey: options } }); + this.client = splitFactory.client(); + } else { + this.client = options.splitClient; + } + this.client.on(this.client.Event.SDK_UPDATE, (payload) => { + this.events.emit(ProviderEvents.ConfigurationChanged, payload) + }); this.initialized = new Promise((resolve) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((this.client as any).__getStatus().isReady) { @@ -121,7 +135,7 @@ export class OpenFeatureSplitProvider implements Provider { } await this.initialized; - const { treatment: value, config }: SplitIO.TreatmentWithConfig = this.client.getTreatmentWithConfig( + const { treatment: value, config }: SplitIO.TreatmentWithConfig = await this.client.getTreatmentWithConfig( consumer.key, flagKey, consumer.attributes From a3f14d72304ed8034da3dca4c01b28271545e36a Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Wed, 10 Sep 2025 18:39:30 -0300 Subject: [PATCH 2/2] Add redis to ci --- .github/workflows/test.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a8fe06..229dfb1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,14 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + + - name: Install Redis + run: | + sudo add-apt-repository ppa:redislabs/redis + sudo apt-get install -y redis-tools redis-server + + - name: Check Redis + run: redis-cli ping - name: Setup Node.js uses: actions/setup-node@v4