diff --git a/CHANGES.txt b/CHANGES.txt index fe5b53f..a88c2d1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,8 @@ +1.1.0 (February 12, 2026) + - Added ProviderEvents.Ready payload with Split SdkReadyMetadata + - Updated ConfigurationChanged to forward SdkUpdateMetadata in metadata + - Requires @splitsoftware/splitio-browserjs ^1.7.0 for SDK_UPDATE metadata support + 1.0.0 (October 1, 2025) -- First release. -- Up to date with @openfeature/web-sdk v1.6.1, and @splitsoftware/splitio-browserjs 1.4.0 \ No newline at end of file + - First release. + - Up to date with @openfeature/web-sdk v1.6.1, and @splitsoftware/splitio-browserjs 1.4.0 \ No newline at end of file diff --git a/README.md b/README.md index 20b23a7..9431b08 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,22 @@ const context: EvaluationContext = { await OpenFeature.setContext(context) ``` +## Configuration changed event (SDK_UPDATE) + +When the Split SDK emits the `SDK_UPDATE` **event** (flags or segments changed), the provider emits OpenFeature’s `ConfigurationChanged` and forwards the event metadata. The metadata shape matches [javascript-commons SdkUpdateMetadata](https://github.com/splitio/javascript-commons): `type` is `'FLAGS_UPDATE' | 'SEGMENTS_UPDATE'` and `names` is the list of flag or segment names that were updated. Handlers receive [Provider Event Details](https://openfeature.dev/specification/types#provider-event-details): `flagsChanged` (when `type === 'FLAGS_UPDATE'`, the `names` array) and `metadata` (`type` as string). + +Requires `@splitsoftware/splitio-browserjs` **1.7.0 or later** (metadata was added in 1.7.0). + +```js +const { OpenFeature, ProviderEvents } = require('@openfeature/web-sdk'); + +const client = OpenFeature.getClient(); +client.addHandler(ProviderEvents.ConfigurationChanged, (eventDetails) => { + console.log('Flags changed:', eventDetails.flagsChanged); + console.log('Event metadata:', eventDetails.metadata); +}); + +``` ## Evaluate with details Use the get*Details(...) APIs to get the value and rich context (variant, reason, error code, metadata). This provider includes the Split treatment config as a raw JSON string under flagMetadata["config"] diff --git a/package-lock.json b/package-lock.json index a70bf26..19d62b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@splitsoftware/openfeature-web-split-provider", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@splitsoftware/openfeature-web-split-provider", - "version": "1.0.0", + "version": "1.1.0", "license": "Apache-2.0", "devDependencies": { "@eslint/js": "^9.35.0", "@openfeature/web-sdk": "^1.6.1", - "@splitsoftware/splitio-browserjs": "^1.4.0", + "@splitsoftware/splitio-browserjs": "^1.7.0", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "eslint": "^9.35.0", @@ -32,7 +32,7 @@ }, "peerDependencies": { "@openfeature/web-sdk": "^1.6.1", - "@splitsoftware/splitio-browserjs": "^1.4.0" + "@splitsoftware/splitio-browserjs": "^1.7.0" } }, "node_modules/@babel/code-frame": { @@ -799,9 +799,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -1263,9 +1263,9 @@ "peer": true }, "node_modules/@openfeature/web-sdk": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.6.1.tgz", - "integrity": "sha512-95nVCIunVj4R7y+4UtaWlYZ0NrscmoUCqf47wnvIfre2+hWiSp77mTPrpYSsebWc8HidyJa7o/NWMk3NOGde3w==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.7.2.tgz", + "integrity": "sha512-8QwhoxVNN2bFFkpWjbCyHCdkVjt/UTVn0o+OwcUUQoZnvPn46Oo1BxJQxUTibl/D/dAM/YQhxmg7ep7gYRxX4g==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -1300,21 +1300,21 @@ } }, "node_modules/@splitsoftware/splitio-browserjs": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-browserjs/-/splitio-browserjs-1.4.0.tgz", - "integrity": "sha512-kOzgV1XzNhyu8T5iPijsHFuelIM1h1ZBh7EfSnTTwxQCIuKZAUkMN6nFq9EvB11mr0ya7rmpSjZZbds9FMFMhw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-browserjs/-/splitio-browserjs-1.7.0.tgz", + "integrity": "sha512-axbpwmVx9P/3/bzWFXBg2hQ6ThJgqi8G89tS4tytWZdbDBXzo6ukNsCYBBWEaOxQEQ+UD+0sd+onngndLHesnA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio-commons": "2.6.0", + "@splitsoftware/splitio-commons": "2.11.0", "tslib": "^2.3.1", "unfetch": "^4.2.0" } }, "node_modules/@splitsoftware/splitio-commons": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.6.0.tgz", - "integrity": "sha512-0xODXLciIvHSuMlb8eukIB2epb3ZyGOsrwS0cMuTdxEvCqr7Nuc9pWDdJtRuN1UwL/jIjBnpDYAc8s6mpqLX2g==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.11.0.tgz", + "integrity": "sha512-/cY9V2CHG2EnOAJp3vVWcs+ZqJ3zqEKHdKX115cK6zHKRMNDXODuPQSX7CIkuCLr6C0kQMQuBnXwcaf5C+cO1A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4393,9 +4393,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e0b3cec..35a27d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/openfeature-web-split-provider", - "version": "1.0.0", + "version": "1.1.0", "description": "Split OpenFeature Web Provider", "files": [ "README.md", @@ -32,12 +32,12 @@ }, "peerDependencies": { "@openfeature/web-sdk": "^1.6.1", - "@splitsoftware/splitio-browserjs": "^1.4.0" + "@splitsoftware/splitio-browserjs": "^1.7.0" }, "devDependencies": { "@eslint/js": "^9.35.0", "@openfeature/web-sdk": "^1.6.1", - "@splitsoftware/splitio-browserjs": "^1.4.0", + "@splitsoftware/splitio-browserjs": "^1.7.0", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "eslint": "^9.35.0", diff --git a/src/__tests__/context.spec.js b/src/__tests__/context.spec.js new file mode 100644 index 0000000..e316ac4 --- /dev/null +++ b/src/__tests__/context.spec.js @@ -0,0 +1,64 @@ +import { transformContext } from '../lib/context'; + +describe('context', () => { + describe('transformContext', () => { + const defaultTrafficType = 'user'; + + test('uses defaultTrafficType when context has no trafficType', () => { + const result = transformContext({ targetingKey: 'key-1' }, defaultTrafficType); + expect(result.trafficType).toBe('user'); + expect(result.targetingKey).toBe('key-1'); + expect(result.attributes).toEqual({}); + }); + + test('uses context trafficType when present and non-empty', () => { + const result = transformContext( + { targetingKey: 'key-1', trafficType: 'account' }, + defaultTrafficType + ); + expect(result.trafficType).toBe('account'); + expect(result.targetingKey).toBe('key-1'); + expect(result.attributes).toEqual({}); + }); + + test('falls back to default when trafficType is empty string', () => { + const result = transformContext( + { targetingKey: 'key-1', trafficType: '' }, + defaultTrafficType + ); + expect(result.trafficType).toBe('user'); + }); + + test('falls back to default when trafficType is whitespace', () => { + const result = transformContext( + { targetingKey: 'key-1', trafficType: ' ' }, + defaultTrafficType + ); + expect(result.trafficType).toBe('user'); + }); + + test('passes remaining context as attributes', () => { + const result = transformContext( + { + targetingKey: 'key-1', + trafficType: 'user', + region: 'eu', + plan: 'pro', + }, + defaultTrafficType + ); + expect(result.attributes).toEqual({ region: 'eu', plan: 'pro' }); + }); + + test('deep-clones attributes (no reference)', () => { + const attrs = { nested: { value: 1 } }; + const result = transformContext( + { targetingKey: 'k', ...attrs }, + defaultTrafficType + ); + expect(result.attributes).toEqual({ nested: { value: 1 } }); + expect(result.attributes).not.toBe(attrs); + expect(result.attributes.nested).not.toBe(attrs.nested); + }); + }); +}); diff --git a/src/__tests__/evaluation.spec.js b/src/__tests__/evaluation.spec.js new file mode 100644 index 0000000..e9aca52 --- /dev/null +++ b/src/__tests__/evaluation.spec.js @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { FlagNotFoundError, StandardResolutionReasons } from '@openfeature/web-sdk'; +import { evaluateTreatment } from '../lib/evaluation'; +import { CONTROL_TREATMENT } from '../lib/types'; + +describe('evaluation', () => { + describe('evaluateTreatment', () => { + let mockClient; + + beforeEach(() => { + mockClient = { + getTreatmentWithConfig: jest.fn((flagKey, attributes) => ({ + treatment: 'v1', + config: '{"x":1}', + })), + }; + }); + + test('returns resolution details with value, variant, flagMetadata, reason', () => { + const consumer = { targetingKey: 'u1', trafficType: 'user', attributes: {} }; + const result = evaluateTreatment(mockClient, 'my-flag', consumer); + + expect(result.value).toBe('v1'); + expect(result.variant).toBe('v1'); + expect(result.flagMetadata).toEqual({ config: '{"x":1}' }); + expect(result.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); + expect(mockClient.getTreatmentWithConfig).toHaveBeenCalledWith('my-flag', {}); + }); + + test('calls getTreatmentWithConfig with consumer attributes', () => { + const consumer = { + targetingKey: 'u1', + trafficType: 'account', + attributes: { region: 'eu', plan: 'pro' }, + }; + evaluateTreatment(mockClient, 'flag', consumer); + expect(mockClient.getTreatmentWithConfig).toHaveBeenCalledWith('flag', { + region: 'eu', + plan: 'pro', + }); + }); + + test('uses empty string for config when config is falsy', () => { + mockClient.getTreatmentWithConfig.mockReturnValue({ treatment: 'on', config: null }); + const result = evaluateTreatment(mockClient, 'f', { + targetingKey: undefined, + trafficType: 'user', + attributes: {}, + }); + expect(result.flagMetadata.config).toBe(''); + }); + + test('throws FlagNotFoundError when flagKey is null', () => { + const consumer = { targetingKey: 'u1', trafficType: 'user', attributes: {} }; + expect(() => evaluateTreatment(mockClient, null, consumer)).toThrow(FlagNotFoundError); + expect(() => evaluateTreatment(mockClient, null, consumer)).toThrow( + /flagKey must be a non-empty string/ + ); + }); + + test('throws FlagNotFoundError when flagKey is empty string', () => { + const consumer = { targetingKey: 'u1', trafficType: 'user', attributes: {} }; + expect(() => evaluateTreatment(mockClient, '', consumer)).toThrow(FlagNotFoundError); + }); + + test('throws FlagNotFoundError when treatment is control', () => { + mockClient.getTreatmentWithConfig.mockReturnValue({ + treatment: CONTROL_TREATMENT, + config: '', + }); + const consumer = { targetingKey: 'u1', trafficType: 'user', attributes: {} }; + expect(() => evaluateTreatment(mockClient, 'flag', consumer)).toThrow(FlagNotFoundError); + expect(() => evaluateTreatment(mockClient, 'flag', consumer)).toThrow(/control/); + }); + }); +}); diff --git a/src/__tests__/events.spec.js b/src/__tests__/events.spec.js new file mode 100644 index 0000000..37efdfd --- /dev/null +++ b/src/__tests__/events.spec.js @@ -0,0 +1,181 @@ +import { ProviderEvents } from '@openfeature/web-sdk'; +import { + attachSplitReadyHandlers, + attachSplitUpdateHandler, +} from '../lib/events'; + +describe('events', () => { + const providerName = 'split'; + + describe('attachSplitReadyHandlers', () => { + test('calls onSdkReady immediately when client is already ready (no SdkReadyMetadata)', () => { + const onSdkReady = jest.fn(); + const onSdkTimedOut = jest.fn(); + const emit = jest.fn(); + const client = { + __getStatus: () => ({ isReady: true, isReadyFromCache: false, hasTimedout: false }), + on: jest.fn(), + Event: { + SDK_READY: 'SDK_READY', + SDK_READY_FROM_CACHE: 'SDK_READY_FROM_CACHE', + SDK_READY_TIMED_OUT: 'SDK_READY_TIMED_OUT', + }, + }; + const eventsEmitter = { emit }; + + attachSplitReadyHandlers(client, eventsEmitter, { onSdkReady, onSdkTimedOut }, providerName); + + expect(emit).toHaveBeenCalledWith(ProviderEvents.Ready, { + providerName, + metadata: {}, + }); + expect(onSdkReady).toHaveBeenCalledTimes(1); + expect(onSdkTimedOut).not.toHaveBeenCalled(); + expect(client.on).not.toHaveBeenCalled(); + }); + + test('calls onSdkReadyFromCache and onSdkTimedOut when ready from cache and timed out', () => { + const onSdkReady = jest.fn(); + const onSdkTimedOut = jest.fn(); + const emit = jest.fn(); + const client = { + __getStatus: () => ({ isReady: false, isReadyFromCache: true, hasTimedout: true }), + on: jest.fn(() => ({})), + Event: { + SDK_READY: 'SDK_READY', + SDK_READY_FROM_CACHE: 'SDK_READY_FROM_CACHE', + SDK_READY_TIMED_OUT: 'SDK_READY_TIMED_OUT', + }, + }; + const eventsEmitter = { emit }; + + attachSplitReadyHandlers(client, eventsEmitter, { onSdkReady, onSdkTimedOut }, providerName); + + expect(emit).toHaveBeenCalledWith(ProviderEvents.Stale, { + message: 'Split ready from cache', + }); + expect(onSdkTimedOut).toHaveBeenCalled(); + }); + + test('registers SDK_READY and on fire emits Ready with Split SdkReadyMetadata', () => { + const onSdkReady = jest.fn(); + const onSdkTimedOut = jest.fn(); + const emit = jest.fn(); + let sdkReadyCallback; + const client = { + __getStatus: () => ({ isReady: false, isReadyFromCache: false, hasTimedout: false }), + on: jest.fn((event, callback) => { + if (event === 'SDK_READY') sdkReadyCallback = callback; + return {}; + }), + Event: { + SDK_READY: 'SDK_READY', + SDK_READY_FROM_CACHE: 'SDK_READY_FROM_CACHE', + SDK_READY_TIMED_OUT: 'SDK_READY_TIMED_OUT', + }, + }; + const eventsEmitter = { emit }; + + attachSplitReadyHandlers(client, eventsEmitter, { onSdkReady, onSdkTimedOut }, providerName); + + expect(client.on).toHaveBeenCalledWith('SDK_READY', expect.any(Function)); + const sdkReadyMetadata = { initialCacheLoad: false, lastUpdateTimestamp: 1234567890 }; + sdkReadyCallback(sdkReadyMetadata); + expect(emit).toHaveBeenCalledWith(ProviderEvents.Ready, { + providerName, + metadata: { initialCacheLoad: false, lastUpdateTimestamp: 1234567890 }, + }); + expect(emit.mock.calls[0][1].metadata).toHaveProperty('initialCacheLoad'); + expect(onSdkReady).toHaveBeenCalled(); + }); + + test('Ready metadata omits lastUpdateTimestamp when undefined (initialCacheLoad true)', () => { + const emit = jest.fn(); + let sdkReadyCallback; + const client = { + __getStatus: () => ({ isReady: false, isReadyFromCache: false, hasTimedout: false }), + on: jest.fn((event, callback) => { + if (event === 'SDK_READY') sdkReadyCallback = callback; + return {}; + }), + Event: { + SDK_READY: 'SDK_READY', + SDK_READY_FROM_CACHE: 'SDK_READY_FROM_CACHE', + SDK_READY_TIMED_OUT: 'SDK_READY_TIMED_OUT', + }, + }; + attachSplitReadyHandlers(client, { emit }, { onSdkReady: () => {}, onSdkTimedOut: () => {} }, providerName); + sdkReadyCallback({ initialCacheLoad: true }); + expect(emit.mock.calls[0][1].metadata).toEqual({ initialCacheLoad: true }); + expect(emit.mock.calls[0][1].metadata).not.toHaveProperty('lastUpdateTimestamp'); + }); + }); + + describe('attachSplitUpdateHandler', () => { + test('registers SDK_UPDATE listener', () => { + const emit = jest.fn(); + let updateCallback; + const client = { + on: jest.fn((event, callback) => { + if (event === 'SDK_UPDATE') updateCallback = callback; + return {}; + }), + Event: { SDK_UPDATE: 'SDK_UPDATE' }, + }; + const eventsEmitter = { emit }; + + attachSplitUpdateHandler(client, eventsEmitter, providerName); + + expect(client.on).toHaveBeenCalledWith('SDK_UPDATE', expect.any(Function)); + + updateCallback({ type: 'FLAGS_UPDATE', names: ['flag-a', 'flag-b'] }); + + expect(emit).toHaveBeenCalledWith(ProviderEvents.ConfigurationChanged, { + providerName, + metadata: { type: 'FLAGS_UPDATE' }, + flagsChanged: ['flag-a', 'flag-b'], + }); + }); + + test('emits with only providerName when updateMetadata is undefined', () => { + const emit = jest.fn(); + let updateCallback; + const client = { + on: jest.fn((event, callback) => { + if (event === 'SDK_UPDATE') updateCallback = callback; + return {}; + }), + Event: { SDK_UPDATE: 'SDK_UPDATE' }, + }; + const eventsEmitter = { emit }; + + attachSplitUpdateHandler(client, eventsEmitter, providerName); + updateCallback(undefined); + + expect(emit).toHaveBeenCalledWith(ProviderEvents.ConfigurationChanged, { + providerName, + }); + }); + + test('emits metadata without flagsChanged when type is not FLAGS_UPDATE', () => { + const emit = jest.fn(); + let updateCallback; + const client = { + on: jest.fn((event, callback) => { + if (event === 'SDK_UPDATE') updateCallback = callback; + return {}; + }), + Event: { SDK_UPDATE: 'SDK_UPDATE' }, + }; + const eventsEmitter = { emit }; + + attachSplitUpdateHandler(client, eventsEmitter, providerName); + updateCallback({ type: 'SPLIT_KILL', names: ['x'] }); + + expect(emit).toHaveBeenCalledWith(ProviderEvents.ConfigurationChanged, { + providerName, + metadata: { type: 'SPLIT_KILL' }, + }); + }); + }); +}); diff --git a/src/__tests__/integration/e2e-events.test.js b/src/__tests__/integration/e2e-events.test.js index 20b1917..0a32372 100644 --- a/src/__tests__/integration/e2e-events.test.js +++ b/src/__tests__/integration/e2e-events.test.js @@ -84,9 +84,6 @@ describe('OpenFeature Split Provider - E2E Integration Tests', () => { OpenFeature.setProvider(provider); const client = OpenFeature.getClient(); await new Promise((resolve, reject) => { - client.addHandler(ProviderEvents.Stale, () => { - reject('should not emit stale'); - }); client.addHandler(ProviderEvents.Ready, () => { const splitClient = splitFactory.client(); try { @@ -122,11 +119,7 @@ describe('OpenFeature Split Provider - E2E Integration Tests', () => { const splitFactory = SplitFactory(config); const client = OpenFeature.getClient(); - await new Promise((resolve, reject) => { - - client.addHandler(ProviderEvents.Stale, () => { - reject('should not emit stale'); - }); + await new Promise((resolve) => { client.addHandler(ProviderEvents.Ready, () => { diff --git a/src/__tests__/integration/openfeature-evaluation.test.js b/src/__tests__/integration/openfeature-evaluation.test.js new file mode 100644 index 0000000..a52b849 --- /dev/null +++ b/src/__tests__/integration/openfeature-evaluation.test.js @@ -0,0 +1,162 @@ +/** + * OpenFeature evaluation tests: validate the Split provider through the + * OpenFeature Web SDK Evaluation API (get*Value, get*Details) and provider lifecycle. + */ +import { OpenFeature, StandardResolutionReasons } from '@openfeature/web-sdk'; +import { OpenFeatureSplitProvider } from '../../lib/js-split-provider'; + +describe('OpenFeature evaluation – Split provider', () => { + let mockSplitClient; + let provider; + let client; + + beforeAll(() => { + mockSplitClient = { + __getStatus: () => ({ isReady: true, isReadyFromCache: false, hasTimedout: false }), + on: jest.fn((event, callback) => { + if (event === 'SDK_READY') setTimeout(() => callback(), 0); + return { id: 'mock-listener' }; + }), + Event: { + SDK_READY: 'SDK_READY', + SDK_READY_FROM_CACHE: 'SDK_READY_FROM_CACHE', + SDK_READY_TIMED_OUT: 'SDK_READY_TIMED_OUT', + SDK_UPDATE: 'SDK_UPDATE', + }, + getTreatmentWithConfig: jest.fn((flagKey) => { + if (flagKey === 'bool_on') return { treatment: 'on', config: '{}' }; + if (flagKey === 'bool_off') return { treatment: 'off', config: '' }; + if (flagKey === 'str_flag') return { treatment: 'v1', config: '{"x":1}' }; + if (flagKey === 'num_flag') return { treatment: '99', config: '{}' }; + if (flagKey === 'obj_flag') return { treatment: '{"a":1}', config: '' }; + return { treatment: 'control', config: '' }; + }), + track: jest.fn(), + destroy: jest.fn(() => Promise.resolve()), + }; + + const factory = { + client: () => mockSplitClient, + destroy: jest.fn(() => Promise.resolve()), + }; + provider = new OpenFeatureSplitProvider(factory); + }); + + beforeEach(async () => { + await OpenFeature.setProviderAndWait(provider); + client = OpenFeature.getClient('evaluation-test'); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearHandlers(); + await OpenFeature.close(); + }); + + describe('Provider metadata', () => { + test('provider has required metadata.name', () => { + expect(provider.metadata).toBeDefined(); + expect(provider.metadata.name).toBe('split'); + }); + }); + + describe('Evaluation API – boolean', () => { + test('getBooleanValue returns true for on', async () => { + const value = await client.getBooleanValue('bool_on', false); + expect(value).toBe(true); + }); + + test('getBooleanValue returns false for off', async () => { + const value = await client.getBooleanValue('bool_off', true); + expect(value).toBe(false); + }); + + test('getBooleanDetails returns correct ResolutionDetails shape', async () => { + const details = await client.getBooleanDetails('bool_on', false); + expect(details.value).toBe(true); + expect(details.flagKey).toBe('bool_on'); + expect(details.variant).toBe('on'); + expect(details.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); + expect(details.flagMetadata).toBeDefined(); + expect(details.flagMetadata.config).toBeDefined(); + }); + }); + + describe('Evaluation API – string', () => { + test('getStringValue returns treatment as string', async () => { + const value = await client.getStringValue('str_flag', 'default'); + expect(value).toBe('v1'); + }); + + test('getStringDetails returns correct ResolutionDetails shape', async () => { + const details = await client.getStringDetails('str_flag', 'default'); + expect(details.value).toBe('v1'); + expect(details.flagKey).toBe('str_flag'); + expect(details.variant).toBe('v1'); + expect(details.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); + expect(details.flagMetadata.config).toBe('{"x":1}'); + }); + }); + + describe('Evaluation API – number', () => { + test('getNumberValue returns parsed number', async () => { + const value = await client.getNumberValue('num_flag', 0); + expect(value).toBe(99); + }); + + test('getNumberDetails returns correct ResolutionDetails shape', async () => { + const details = await client.getNumberDetails('num_flag', 0); + expect(details.value).toBe(99); + expect(details.flagKey).toBe('num_flag'); + expect(details.variant).toBe('99'); + expect(details.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); + }); + }); + + describe('Evaluation API – object', () => { + test('getObjectValue returns parsed JSON object', async () => { + const value = await client.getObjectValue('obj_flag', {}); + expect(value).toEqual({ a: 1 }); + }); + + test('getObjectDetails returns correct ResolutionDetails shape', async () => { + const details = await client.getObjectDetails('obj_flag', {}); + expect(details.value).toEqual({ a: 1 }); + expect(details.flagKey).toBe('obj_flag'); + expect(details.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); + }); + }); + + describe('Context and targeting', () => { + test('getTreatmentWithConfig is called with context attributes', async () => { + await client.getBooleanValue('bool_on', false); + expect(mockSplitClient.getTreatmentWithConfig).toHaveBeenCalledWith('bool_on', {}); + }); + + test('context attributes are passed to Split', async () => { + await OpenFeature.setContext({ targetingKey: 'user-1', region: 'eu' }); + await client.getBooleanValue('bool_on', false); + expect(mockSplitClient.getTreatmentWithConfig).toHaveBeenCalledWith('bool_on', { region: 'eu' }); + }); + }); + + describe('Control / flag not found', () => { + test('control treatment: client returns default value', async () => { + const value = await client.getStringValue('unknown_flag', 'default'); + expect(value).toBe('default'); + }); + test('control treatment: getStringDetails reveals FLAG_NOT_FOUND', async () => { + const details = await client.getStringDetails('unknown_flag', 'default'); + expect(details.value).toBe('default'); + expect(details.reason).toBe(StandardResolutionReasons.ERROR); + }); + }); + + describe('Provider events', () => { + test('provider exposes events emitter', () => { + expect(provider.events).toBeDefined(); + expect(typeof provider.events.emit).toBe('function'); + expect(typeof provider.events.addHandler).toBe('function'); + }); + }); +}); diff --git a/src/__tests__/parsers.spec.js b/src/__tests__/parsers.spec.js new file mode 100644 index 0000000..ed4b79a --- /dev/null +++ b/src/__tests__/parsers.spec.js @@ -0,0 +1,115 @@ +import { + parseValidNumber, + parseValidJsonObject, + parseBooleanTreatment, +} from '../lib/parsers'; +import { ParseError } from '@openfeature/web-sdk'; + +describe('parsers', () => { + describe('parseValidNumber', () => { + test('parses integer string', () => { + expect(parseValidNumber('42')).toBe(42); + }); + + test('parses float string', () => { + expect(parseValidNumber('3.14')).toBe(3.14); + }); + + test('parses zero', () => { + expect(parseValidNumber('0')).toBe(0); + }); + + test('parses negative number', () => { + expect(parseValidNumber('-10')).toBe(-10); + }); + + test('throws ParseError for undefined', () => { + expect(() => parseValidNumber(undefined)).toThrow(ParseError); + expect(() => parseValidNumber(undefined)).toThrow(/undefined/); + }); + + test('throws ParseError for non-numeric string', () => { + expect(() => parseValidNumber('not-a-number')).toThrow(ParseError); + expect(() => parseValidNumber('not-a-number')).toThrow(/Invalid numeric value/); + }); + + test('throws ParseError for empty string', () => { + expect(() => parseValidNumber('')).toThrow(ParseError); + }); + }); + + describe('parseValidJsonObject', () => { + test('parses plain object', () => { + expect(parseValidJsonObject('{"a":1}')).toEqual({ a: 1 }); + }); + + test('parses nested object', () => { + expect(parseValidJsonObject('{"key":"value","nested":{"inner":"data"}}')).toEqual({ + key: 'value', + nested: { inner: 'data' }, + }); + }); + + test('parses array (object type)', () => { + expect(parseValidJsonObject('[1,2,3]')).toEqual([1, 2, 3]); + }); + + test('throws ParseError for undefined', () => { + expect(() => parseValidJsonObject(undefined)).toThrow(ParseError); + expect(() => parseValidJsonObject(undefined)).toThrow(/undefined/); + }); + + test('throws ParseError for non-object JSON (string)', () => { + expect(() => parseValidJsonObject('"hello"')).toThrow(ParseError); + expect(() => parseValidJsonObject('"hello"')).toThrow(/expected "object"/); + }); + + test('throws ParseError for non-object JSON (number)', () => { + expect(() => parseValidJsonObject('42')).toThrow(ParseError); + }); + + test('throws ParseError for invalid JSON', () => { + expect(() => parseValidJsonObject('{ invalid }')).toThrow(ParseError); + expect(() => parseValidJsonObject('{ invalid }')).toThrow(/Error parsing/); + }); + }); + + describe('parseBooleanTreatment', () => { + test('returns true for "on"', () => { + expect(parseBooleanTreatment('on')).toBe(true); + }); + + test('returns true for "ON" (case insensitive)', () => { + expect(parseBooleanTreatment('ON')).toBe(true); + }); + + test('returns true for "true"', () => { + expect(parseBooleanTreatment('true')).toBe(true); + }); + + test('returns true for "TRUE"', () => { + expect(parseBooleanTreatment('TRUE')).toBe(true); + }); + + test('returns false for "off"', () => { + expect(parseBooleanTreatment('off')).toBe(false); + }); + + test('returns false for "OFF"', () => { + expect(parseBooleanTreatment('OFF')).toBe(false); + }); + + test('returns false for "false"', () => { + expect(parseBooleanTreatment('false')).toBe(false); + }); + + test('throws ParseError for invalid treatment', () => { + expect(() => parseBooleanTreatment('maybe')).toThrow(ParseError); + expect(() => parseBooleanTreatment('maybe')).toThrow(/Invalid boolean value for maybe/); + }); + + test('throws ParseError for unknown string', () => { + expect(() => parseBooleanTreatment('v1')).toThrow(ParseError); + }); + }); +}); diff --git a/src/__tests__/provider.spec.js b/src/__tests__/provider.spec.js index 9c92c49..786c537 100644 --- a/src/__tests__/provider.spec.js +++ b/src/__tests__/provider.spec.js @@ -1,5 +1,6 @@ /* eslint-disable jest/no-conditional-expect */ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { ProviderEvents } from '@openfeature/web-sdk'; import { OpenFeatureSplitProvider } from '../lib/js-split-provider'; describe('OpenFeatureSplitProvider Unit Tests', () => { @@ -21,8 +22,11 @@ describe('OpenFeatureSplitProvider Unit Tests', () => { } return { id: 'mock-listener' }; }), - // Define SDK_READY event constant - Event: { SDK_READY: 'SDK_READY' }, + // Define event constants used by provider + Event: { + SDK_READY: 'SDK_READY', + SDK_UPDATE: 'SDK_UPDATE', + }, // Mock the treatments getTreatmentWithConfig: jest.fn((flagKey, _attributes) => { @@ -159,5 +163,58 @@ describe('OpenFeatureSplitProvider Unit Tests', () => { expect(trackSpy).toHaveBeenCalledTimes(1); expect(trackSpy).toHaveBeenCalledWith('user', 'purchase', 9.99, { plan: 'pro', beta: true }); }); - + + describe('SDK_UPDATE / ConfigurationChanged', () => { + beforeEach(async () => { + await provider.initialize(); + }); + + function getSdkUpdateCallback() { + const call = mockSplitClient.on.mock.calls.find((c) => c[0] === 'SDK_UPDATE'); + return call ? call[1] : null; + } + + test('emits ConfigurationChanged with only providerName when updateMetadata is undefined', () => { + const emitSpy = jest.spyOn(provider.events, 'emit'); + const sdkUpdateCallback = getSdkUpdateCallback(); + expect(sdkUpdateCallback).toBeDefined(); + + sdkUpdateCallback(undefined); + + expect(emitSpy).toHaveBeenCalledWith(ProviderEvents.ConfigurationChanged, { + providerName: provider.metadata.name, + }); + }); + + test('emits ConfigurationChanged with metadata when updateMetadata is provided', () => { + const emitSpy = jest.spyOn(provider.events, 'emit'); + const sdkUpdateCallback = getSdkUpdateCallback(); + const updateMetadata = { type: 'SPLIT_KILL', names: ['flag-a', 'flag-b'] }; + + sdkUpdateCallback(updateMetadata); + + expect(emitSpy).toHaveBeenCalledWith(ProviderEvents.ConfigurationChanged, { + providerName: provider.metadata.name, + metadata: { + type: 'SPLIT_KILL', + }, + }); + }); + + test('emits ConfigurationChanged with flagsChanged when updateMetadata.type is FLAGS_UPDATE', () => { + const emitSpy = jest.spyOn(provider.events, 'emit'); + const sdkUpdateCallback = getSdkUpdateCallback(); + const updateMetadata = { type: 'FLAGS_UPDATE', names: ['my-flag', 'other-flag'] }; + + sdkUpdateCallback(updateMetadata); + + expect(emitSpy).toHaveBeenCalledWith(ProviderEvents.ConfigurationChanged, { + providerName: provider.metadata.name, + metadata: { + type: 'FLAGS_UPDATE', + }, + flagsChanged: ['my-flag', 'other-flag'], + }); + }); + }); }); diff --git a/src/__tests__/types.spec.js b/src/__tests__/types.spec.js new file mode 100644 index 0000000..f6c3467 --- /dev/null +++ b/src/__tests__/types.spec.js @@ -0,0 +1,15 @@ +import { + CONTROL_TREATMENT, + CONTROL_VALUE_ERROR_MESSAGE, +} from '../lib/types'; + +describe('types', () => { + test('CONTROL_TREATMENT is "control"', () => { + expect(CONTROL_TREATMENT).toBe('control'); + }); + + test('CONTROL_VALUE_ERROR_MESSAGE mentions control', () => { + expect(CONTROL_VALUE_ERROR_MESSAGE).toContain('control'); + expect(CONTROL_VALUE_ERROR_MESSAGE).toBe("Received the 'control' value from Split."); + }); +}); diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 index 0000000..a697a6a --- /dev/null +++ b/src/lib/context.ts @@ -0,0 +1,21 @@ +import type { EvaluationContext } from "@openfeature/web-sdk"; +import type { Consumer } from "./types"; + +/** + * Transform OpenFeature evaluation context into a consumer object for the Split API. + */ +export function transformContext( + context: EvaluationContext, + defaultTrafficType: string +): Consumer { + const { targetingKey, trafficType: ttVal, ...attributes } = context; + const trafficType = + ttVal != null && typeof ttVal === 'string' && ttVal.trim() !== '' + ? ttVal + : defaultTrafficType; + return { + targetingKey, + trafficType, + attributes: JSON.parse(JSON.stringify(attributes)), + }; +} diff --git a/src/lib/evaluation.ts b/src/lib/evaluation.ts new file mode 100644 index 0000000..909ca36 --- /dev/null +++ b/src/lib/evaluation.ts @@ -0,0 +1,33 @@ +import type { ResolutionDetails } from "@openfeature/web-sdk"; +import { FlagNotFoundError, StandardResolutionReasons } from "@openfeature/web-sdk"; +import type SplitIO from "@splitsoftware/splitio-browserjs/types/splitio"; +import { CONTROL_TREATMENT, CONTROL_VALUE_ERROR_MESSAGE } from "./types"; +import type { Consumer } from "./types"; + +/** + * Evaluate a flag with the Split client and return string resolution details. + */ +export function evaluateTreatment( + client: SplitIO.IBrowserClient, + flagKey: string, + consumer: Consumer +): ResolutionDetails { + if (flagKey == null || flagKey === '') { + throw new FlagNotFoundError('flagKey must be a non-empty string'); + } + const treatment: SplitIO.TreatmentWithConfig = client.getTreatmentWithConfig( + flagKey, + consumer.attributes + ); + const { treatment: value, config } = treatment; + + if (value === CONTROL_TREATMENT) { + throw new FlagNotFoundError(CONTROL_VALUE_ERROR_MESSAGE); + } + return { + value, + variant: value, + flagMetadata: { config: config ? config : '' }, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; +} diff --git a/src/lib/events.ts b/src/lib/events.ts new file mode 100644 index 0000000..05c2004 --- /dev/null +++ b/src/lib/events.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ProviderEvents } from "@openfeature/web-sdk"; +import type { EventDetails, EventMetadata, OpenFeatureEventEmitter } from "@openfeature/web-sdk"; +import type SplitIO from "@splitsoftware/splitio-browserjs/types/splitio"; + +/** + * Split SDK emits SDK_READY with SdkReadyMetadata: + * - initialCacheLoad: boolean — true when fresh install / no cache, false when ready from cache + * - lastUpdateTimestamp?: number — ms since epoch when cache was last updated (undefined if initialCacheLoad is true) + */ + +export type ReadyCallbacks = { + onSdkReady: () => void; + onSdkTimedOut: () => void; +}; + +/** Map Split SdkReadyMetadata to OpenFeature EventMetadata (string | boolean | number). */ +function toReadyEventMetadata(sdkReadyMetadata: SplitIO.SdkReadyMetadata): EventMetadata { + const meta: EventMetadata = { + initialCacheLoad: sdkReadyMetadata.initialCacheLoad, + }; + if (sdkReadyMetadata.lastUpdateTimestamp != null) { + meta.lastUpdateTimestamp = sdkReadyMetadata.lastUpdateTimestamp; + } + return meta; +} + +/** + * Emit OpenFeature ProviderEvents.Ready with Split SDK ready metadata, then invoke the callback. + * When sdkReadyMetadata is provided (from SDK_READY event), use it; otherwise emit with empty metadata (e.g. when already ready). + */ +function emitReadyWithSplitMetadata( + eventsEmitter: OpenFeatureEventEmitter, + providerName: string, + onSdkReady: () => void, + sdkReadyMetadata?: SplitIO.SdkReadyMetadata +): void { + const readyDetails: EventDetails = { + providerName, + metadata: sdkReadyMetadata ? toReadyEventMetadata(sdkReadyMetadata) : {}, + }; + eventsEmitter.emit(ProviderEvents.Ready, readyDetails); + onSdkReady(); +} + +/** + * Attach Split SDK event listeners and resolve/reject the ready promise based on client status. + * When the Split SDK becomes ready, emits ProviderEvents.Ready with Split ready metadata before resolving. + */ +export function attachSplitReadyHandlers( + client: SplitIO.IBrowserClient, + eventsEmitter: OpenFeatureEventEmitter, + callbacks: ReadyCallbacks, + providerName: string +): void { + const { onSdkReady, onSdkTimedOut } = callbacks; + + const onSdkReadyFromCache = () => { + eventsEmitter.emit(ProviderEvents.Stale, { message: 'Split ready from cache' }); + }; + + const clientStatus = (client as any).__getStatus(); + if (clientStatus.isReady) { + emitReadyWithSplitMetadata(eventsEmitter, providerName, onSdkReady); + return; + } + + if (clientStatus.isReadyFromCache) { + onSdkReadyFromCache(); + } else { + client.on(client.Event.SDK_READY_FROM_CACHE, onSdkReadyFromCache); + } + + if (clientStatus.hasTimedout) { + onSdkTimedOut(); + } else { + client.on(client.Event.SDK_READY_TIMED_OUT, onSdkTimedOut); + } + client.on(client.Event.SDK_READY, (sdkReadyMetadata?: SplitIO.SdkReadyMetadata) => { + emitReadyWithSplitMetadata(eventsEmitter, providerName, onSdkReady, sdkReadyMetadata); + }); +} + +/** + * Subscribe to Split SDK_UPDATE and emit OpenFeature ConfigurationChanged with details. + */ +export function attachSplitUpdateHandler( + client: SplitIO.IBrowserClient, + eventsEmitter: OpenFeatureEventEmitter, + providerName: string +): void { + client.on(client.Event.SDK_UPDATE, (updateMetadata: SplitIO.SdkUpdateMetadata) => { + let eventDetails: EventDetails = { providerName }; + if (updateMetadata) { + eventDetails = { + ...eventDetails, + metadata: { + type: updateMetadata.type, + }, + }; + if (updateMetadata.type === 'FLAGS_UPDATE') { + eventDetails = { ...eventDetails, flagsChanged: updateMetadata.names }; + } + } + eventsEmitter.emit(ProviderEvents.ConfigurationChanged, eventDetails); + }); +} diff --git a/src/lib/js-split-provider.ts b/src/lib/js-split-provider.ts index ec6d761..7e56f01 100644 --- a/src/lib/js-split-provider.ts +++ b/src/lib/js-split-provider.ts @@ -1,13 +1,10 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ import { EvaluationContext, Provider, ResolutionDetails, ParseError, - FlagNotFoundError, JsonValue, - StandardResolutionReasons, Logger, ProviderEvents, OpenFeatureEventEmitter, @@ -15,84 +12,82 @@ import { } from "@openfeature/web-sdk"; import type SplitIO from "@splitsoftware/splitio-browserjs/types/splitio"; -type Consumer = { - targetingKey: string | undefined; - trafficType: string; - attributes: SplitIO.Attributes; -}; - -const CONTROL_VALUE_ERROR_MESSAGE = "Received the 'control' value from Split."; -const CONTROL_TREATMENT = 'control'; +import { transformContext } from "./context"; +import { attachSplitReadyHandlers, attachSplitUpdateHandler } from "./events"; +import { evaluateTreatment } from "./evaluation"; +import { parseBooleanTreatment, parseValidJsonObject, parseValidNumber } from "./parsers"; export class OpenFeatureSplitProvider implements Provider { - metadata = { - name: "split", - }; + metadata = { name: "split" }; + private client: SplitIO.IBrowserClient; private factory: SplitIO.IBrowserSDK; private trafficType: string; public events = new OpenFeatureEventEmitter(); + constructor(splitFactory: SplitIO.IBrowserSDK) { + this.trafficType = 'user'; + this.factory = splitFactory; + this.client = splitFactory.client(); + } + + async initialize(): Promise { + await new Promise((resolve, reject) => { + try { + attachSplitReadyHandlers( + this.client, + this.events, + { onSdkReady: resolve as () => void, onSdkTimedOut: reject }, + this.metadata.name + ); + attachSplitUpdateHandler(this.client, this.events, this.metadata.name); + } catch { + reject(); + } + }); + } + onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise { const { targetingKey: oldTargetingKey } = oldContext; const { targetingKey: newTargetingKey, trafficType: newTrafficType } = newContext; - this.trafficType = newTrafficType && newTrafficType !== this.trafficType ? newTrafficType as string : this.trafficType ; + this.trafficType = + newTrafficType && newTrafficType !== this.trafficType + ? (newTrafficType as string) + : this.trafficType; + if (newTargetingKey && newTargetingKey !== oldTargetingKey) { this.client = this.factory.client(newTargetingKey); - return new Promise((resolve, reject) => { const emitContextChange = () => { this.events.emit(ProviderEvents.ConfigurationChanged); resolve(); }; - - this.eventsHandler(emitContextChange, reject); + attachSplitReadyHandlers( + this.client, + this.events, + { onSdkReady: emitContextChange, onSdkTimedOut: reject }, + this.metadata.name + ); + attachSplitUpdateHandler(this.client, this.events, this.metadata.name); }); } return Promise.resolve(); } - constructor(splitFactory: SplitIO.IBrowserSDK) { - // Asume 'user' as default traffic type' - this.trafficType = 'user'; - this.factory = splitFactory; - this.client = splitFactory.client(); - } - - async initialize(): Promise { - - await new Promise ((resolve, reject) => { - try { - this.eventsHandler(resolve, reject); - } catch { - reject(); - } - }); - } - resolveBooleanEvaluation( flagKey: string, _: boolean, context: EvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.evaluateTreatment( + const details = evaluateTreatment( + this.client, flagKey, - this.transformContext(context), + transformContext(context, this.trafficType) ); - - const treatment = details.value.toLowerCase(); - - if ( treatment === 'on' || treatment === 'true' ) { - return { ...details, value: true }; - } - - if ( treatment === 'off' || treatment === 'false' ) { - return { ...details, value: false }; - } - - throw new ParseError(`Invalid boolean value for ${treatment}`); + const value = parseBooleanTreatment(details.value); + return { ...details, value }; } resolveStringEvaluation( @@ -101,11 +96,11 @@ export class OpenFeatureSplitProvider implements Provider { context: EvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.evaluateTreatment( + return evaluateTreatment( + this.client, flagKey, - this.transformContext(context), + transformContext(context, this.trafficType) ); - return details; } resolveNumberEvaluation( @@ -114,11 +109,12 @@ export class OpenFeatureSplitProvider implements Provider { context: EvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.evaluateTreatment( + const details = evaluateTreatment( + this.client, flagKey, - this.transformContext(context), + transformContext(context, this.trafficType) ); - return { ...details, value: this.parseValidNumber(details.value) }; + return { ...details, value: parseValidNumber(details.value) }; } resolveObjectEvaluation( @@ -127,39 +123,12 @@ export class OpenFeatureSplitProvider implements Provider { context: EvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.evaluateTreatment( - flagKey, - this.transformContext(context) - ); - return { ...details, value: this.parseValidJsonObject(details.value) }; - } - - private evaluateTreatment( - flagKey: string, - consumer: Consumer - ): ResolutionDetails { - if (flagKey == null || flagKey === '') { - throw new FlagNotFoundError( - 'flagKey must be a non-empty string' - ); - } - const treatment: SplitIO.TreatmentWithConfig = this.client.getTreatmentWithConfig( + const details = evaluateTreatment( + this.client, flagKey, - consumer.attributes + transformContext(context, this.trafficType) ); - const {treatment: value, config} = treatment; - - if (value === CONTROL_TREATMENT) { - throw new FlagNotFoundError(CONTROL_VALUE_ERROR_MESSAGE); - } - const flagMetadata = { config: config ? config : '' }; - const details: ResolutionDetails = { - value: value, - variant: value, - flagMetadata: flagMetadata, - reason: StandardResolutionReasons.TARGETING_MATCH, - }; - return details; + return { ...details, value: parseValidJsonObject(details.value) }; } track( @@ -167,105 +136,22 @@ export class OpenFeatureSplitProvider implements Provider { context: EvaluationContext, details: TrackingEventDetails ): void { - - // eventName is always required - if (trackingEventName == null || trackingEventName === '') + if (trackingEventName == null || trackingEventName === '') { throw new ParseError('Missing eventName, required to track'); - - const {trafficType} = this.transformContext(context); - let value; + } + const { trafficType } = transformContext(context, this.trafficType); + let value: unknown; let properties: SplitIO.Properties = {}; if (details != null) { - if (details.value != null) { - value = details.value; - } + if (details.value != null) value = details.value; if (details.properties != null) { properties = details.properties as SplitIO.Properties; } - } - - this.client.track(trafficType, trackingEventName, value, properties); + } + this.client.track(trafficType, trackingEventName, value as number | undefined, properties); } async onClose?(): Promise { return this.factory.destroy(); } - - //Transform the context into an object useful for the Split API, an key string with arbitrary Split "Attributes". - private transformContext(context: EvaluationContext): Consumer { - const { targetingKey, trafficType: ttVal, ...attributes } = context; - const trafficType = - ttVal != null && typeof ttVal === 'string' && ttVal.trim() !== '' - ? ttVal - : this.trafficType; - return { - targetingKey, - trafficType, - // Stringify context objects include date. - attributes: JSON.parse(JSON.stringify(attributes)), - }; - } - - private parseValidNumber(stringValue: string | undefined) { - if (stringValue === undefined) { - throw new ParseError(`Invalid 'undefined' value.`); - } - const result = Number.parseFloat(stringValue); - if (Number.isNaN(result)) { - throw new ParseError(`Invalid numeric value ${stringValue}`); - } - return result; - } - - private parseValidJsonObject( - stringValue: string | undefined - ): T { - if (stringValue === undefined) { - throw new ParseError(`Invalid 'undefined' JSON value.`); - } - // we may want to allow the parsing to be customized. - try { - const value = JSON.parse(stringValue); - if (typeof value !== "object") { - throw new ParseError( - `Flag value ${stringValue} had unexpected type ${typeof value}, expected "object"` - ); - } - return value; - } catch (err) { - throw new ParseError(`Error parsing ${stringValue} as JSON, ${err}`); - } - } - - private async eventsHandler(onSdkReady: (params?: any) => void, onSdkTimedOut: () => void): Promise { - - const onSdkReadyFromCache = () => { - this.events.emit(ProviderEvents.Stale, { - message: `Split ready from cache`, - }); - }; - - const clientStatus = (this.client as any).__getStatus(); - if (clientStatus.isReady) { - onSdkReady(); - } else { - - if (clientStatus.isReadyFromCache) { - onSdkReadyFromCache(); - } else { - this.client.on(this.client.Event.SDK_READY_FROM_CACHE, onSdkReadyFromCache); - } - - if (clientStatus.hasTimedout) { - onSdkTimedOut(); - } else { - this.client.on(this.client.Event.SDK_READY_TIMED_OUT, onSdkTimedOut); - } - this.client.on(this.client.Event.SDK_READY, onSdkReady); - } - - this.client.on(this.client.Event.SDK_UPDATE, () => { - this.events.emit(ProviderEvents.ConfigurationChanged); - }); - } } diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts new file mode 100644 index 0000000..96e26a0 --- /dev/null +++ b/src/lib/parsers.ts @@ -0,0 +1,43 @@ +import { ParseError } from "@openfeature/web-sdk"; +import type { JsonValue } from "@openfeature/web-sdk"; + +export function parseValidNumber(stringValue: string | undefined): number { + if (stringValue === undefined) { + throw new ParseError(`Invalid 'undefined' value.`); + } + const result = Number.parseFloat(stringValue); + if (Number.isNaN(result)) { + throw new ParseError(`Invalid numeric value ${stringValue}`); + } + return result; +} + +export function parseValidJsonObject( + stringValue: string | undefined +): T { + if (stringValue === undefined) { + throw new ParseError(`Invalid 'undefined' JSON value.`); + } + try { + const value = JSON.parse(stringValue); + if (typeof value !== "object") { + throw new ParseError( + `Flag value ${stringValue} had unexpected type ${typeof value}, expected "object"` + ); + } + return value; + } catch (err) { + throw new ParseError(`Error parsing ${stringValue} as JSON, ${err}`); + } +} + +/** + * Map a string treatment to a boolean for boolean flag resolution. + * @throws ParseError if treatment is not a valid boolean representation + */ +export function parseBooleanTreatment(treatment: string): boolean { + const lower = treatment.toLowerCase(); + if (lower === 'on' || lower === 'true') return true; + if (lower === 'off' || lower === 'false') return false; + throw new ParseError(`Invalid boolean value for ${treatment}`); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..2fff960 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,10 @@ +import type SplitIO from "@splitsoftware/splitio-browserjs/types/splitio"; + +export type Consumer = { + targetingKey: string | undefined; + trafficType: string; + attributes: SplitIO.Attributes; +}; + +export const CONTROL_TREATMENT = 'control'; +export const CONTROL_VALUE_ERROR_MESSAGE = "Received the 'control' value from Split.";