diff --git a/Makefile b/Makefile index 18cf33b..c77f1ef 100644 --- a/Makefile +++ b/Makefile @@ -34,8 +34,7 @@ test-data: rm -rf $(testDataDir) mkdir -p $(tempDir) git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} - cp ${gitDataDir}rac-experiments-v3-obfuscated.json ${testDataDir} - cp -r ${gitDataDir}assignment-v2 ${testDataDir} + cp -r ${gitDataDir}ufc ${testDataDir} rm -rf ${tempDir} ## prepare diff --git a/docs/js-client-sdk.eppojsclient.getassignment.md b/docs/js-client-sdk.eppojsclient.getassignment.md deleted file mode 100644 index 7ecbce3..0000000 --- a/docs/js-client-sdk.eppojsclient.getassignment.md +++ /dev/null @@ -1,25 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [getAssignment](./js-client-sdk.eppojsclient.getassignment.md) - -## EppoJSClient.getAssignment() method - -**Signature:** - -```typescript -getAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): string | null; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| subjectKey | string | | -| flagKey | string | | -| subjectAttributes | Record<string, any> | _(Optional)_ | -| assignmentHooks | IAssignmentHooks | _(Optional)_ | - -**Returns:** - -string \| null - diff --git a/docs/js-client-sdk.eppojsclient.getboolassignment.md b/docs/js-client-sdk.eppojsclient.getboolassignment.md index d1b9115..3a9028a 100644 --- a/docs/js-client-sdk.eppojsclient.getboolassignment.md +++ b/docs/js-client-sdk.eppojsclient.getboolassignment.md @@ -7,19 +7,19 @@ **Signature:** ```typescript -getBoolAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): boolean | null; +getBoolAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: boolean): boolean; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| subjectKey | string | | | flagKey | string | | -| subjectAttributes | Record<string, any> | _(Optional)_ | -| assignmentHooks | IAssignmentHooks | _(Optional)_ | +| subjectKey | string | | +| subjectAttributes | Record<string, any> | | +| defaultValue | boolean | | **Returns:** -boolean \| null +boolean diff --git a/docs/js-client-sdk.eppojsclient.getintegerassignment.md b/docs/js-client-sdk.eppojsclient.getintegerassignment.md new file mode 100644 index 0000000..ff6d074 --- /dev/null +++ b/docs/js-client-sdk.eppojsclient.getintegerassignment.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [getIntegerAssignment](./js-client-sdk.eppojsclient.getintegerassignment.md) + +## EppoJSClient.getIntegerAssignment() method + +**Signature:** + +```typescript +getIntegerAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: number): number; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| flagKey | string | | +| subjectKey | string | | +| subjectAttributes | Record<string, any> | | +| defaultValue | number | | + +**Returns:** + +number + diff --git a/docs/js-client-sdk.eppojsclient.getjsonassignment.md b/docs/js-client-sdk.eppojsclient.getjsonassignment.md new file mode 100644 index 0000000..3359350 --- /dev/null +++ b/docs/js-client-sdk.eppojsclient.getjsonassignment.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [getJSONAssignment](./js-client-sdk.eppojsclient.getjsonassignment.md) + +## EppoJSClient.getJSONAssignment() method + +**Signature:** + +```typescript +getJSONAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: object): object; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| flagKey | string | | +| subjectKey | string | | +| subjectAttributes | Record<string, any> | | +| defaultValue | object | | + +**Returns:** + +object + diff --git a/docs/js-client-sdk.eppojsclient.getjsonstringassignment.md b/docs/js-client-sdk.eppojsclient.getjsonstringassignment.md deleted file mode 100644 index ab9177e..0000000 --- a/docs/js-client-sdk.eppojsclient.getjsonstringassignment.md +++ /dev/null @@ -1,25 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [getJSONStringAssignment](./js-client-sdk.eppojsclient.getjsonstringassignment.md) - -## EppoJSClient.getJSONStringAssignment() method - -**Signature:** - -```typescript -getJSONStringAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): string | null; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| subjectKey | string | | -| flagKey | string | | -| subjectAttributes | Record<string, any> | _(Optional)_ | -| assignmentHooks | IAssignmentHooks | _(Optional)_ | - -**Returns:** - -string \| null - diff --git a/docs/js-client-sdk.eppojsclient.getnumericassignment.md b/docs/js-client-sdk.eppojsclient.getnumericassignment.md index 95f8f77..222ac84 100644 --- a/docs/js-client-sdk.eppojsclient.getnumericassignment.md +++ b/docs/js-client-sdk.eppojsclient.getnumericassignment.md @@ -7,19 +7,19 @@ **Signature:** ```typescript -getNumericAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): number | null; +getNumericAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: number): number; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| subjectKey | string | | | flagKey | string | | -| subjectAttributes | Record<string, any> | _(Optional)_ | -| assignmentHooks | IAssignmentHooks | _(Optional)_ | +| subjectKey | string | | +| subjectAttributes | Record<string, any> | | +| defaultValue | number | | **Returns:** -number \| null +number diff --git a/docs/js-client-sdk.eppojsclient.getparsedjsonassignment.md b/docs/js-client-sdk.eppojsclient.getparsedjsonassignment.md deleted file mode 100644 index fff2967..0000000 --- a/docs/js-client-sdk.eppojsclient.getparsedjsonassignment.md +++ /dev/null @@ -1,25 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [EppoJSClient](./js-client-sdk.eppojsclient.md) > [getParsedJSONAssignment](./js-client-sdk.eppojsclient.getparsedjsonassignment.md) - -## EppoJSClient.getParsedJSONAssignment() method - -**Signature:** - -```typescript -getParsedJSONAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): object | null; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| subjectKey | string | | -| flagKey | string | | -| subjectAttributes | Record<string, any> | _(Optional)_ | -| assignmentHooks | IAssignmentHooks | _(Optional)_ | - -**Returns:** - -object \| null - diff --git a/docs/js-client-sdk.eppojsclient.getstringassignment.md b/docs/js-client-sdk.eppojsclient.getstringassignment.md index cae2f6c..c3c1b0a 100644 --- a/docs/js-client-sdk.eppojsclient.getstringassignment.md +++ b/docs/js-client-sdk.eppojsclient.getstringassignment.md @@ -7,19 +7,19 @@ **Signature:** ```typescript -getStringAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): string | null; +getStringAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: string): string; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| subjectKey | string | | | flagKey | string | | -| subjectAttributes | Record<string, any> | _(Optional)_ | -| assignmentHooks | IAssignmentHooks | _(Optional)_ | +| subjectKey | string | | +| subjectAttributes | Record<string, any> | | +| defaultValue | string | | **Returns:** -string \| null +string diff --git a/docs/js-client-sdk.eppojsclient.md b/docs/js-client-sdk.eppojsclient.md index 673ad5d..a797247 100644 --- a/docs/js-client-sdk.eppojsclient.md +++ b/docs/js-client-sdk.eppojsclient.md @@ -24,10 +24,9 @@ export declare class EppoJSClient extends EppoClient | Method | Modifiers | Description | | --- | --- | --- | -| [getAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks)](./js-client-sdk.eppojsclient.getassignment.md) | | | -| [getBoolAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks)](./js-client-sdk.eppojsclient.getboolassignment.md) | | | -| [getJSONStringAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks)](./js-client-sdk.eppojsclient.getjsonstringassignment.md) | | | -| [getNumericAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks)](./js-client-sdk.eppojsclient.getnumericassignment.md) | | | -| [getParsedJSONAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks)](./js-client-sdk.eppojsclient.getparsedjsonassignment.md) | | | -| [getStringAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks)](./js-client-sdk.eppojsclient.getstringassignment.md) | | | +| [getBoolAssignment(flagKey, subjectKey, subjectAttributes, defaultValue)](./js-client-sdk.eppojsclient.getboolassignment.md) | | | +| [getIntegerAssignment(flagKey, subjectKey, subjectAttributes, defaultValue)](./js-client-sdk.eppojsclient.getintegerassignment.md) | | | +| [getJSONAssignment(flagKey, subjectKey, subjectAttributes, defaultValue)](./js-client-sdk.eppojsclient.getjsonassignment.md) | | | +| [getNumericAssignment(flagKey, subjectKey, subjectAttributes, defaultValue)](./js-client-sdk.eppojsclient.getnumericassignment.md) | | | +| [getStringAssignment(flagKey, subjectKey, subjectAttributes, defaultValue)](./js-client-sdk.eppojsclient.getstringassignment.md) | | | diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index 93bfe14..22d86ba 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -6,24 +6,21 @@ import { EppoClient } from '@eppo/js-client-sdk-common'; import { IAssignmentEvent } from '@eppo/js-client-sdk-common'; -import { IAssignmentHooks } from '@eppo/js-client-sdk-common'; import { IAssignmentLogger } from '@eppo/js-client-sdk-common'; import { IEppoClient } from '@eppo/js-client-sdk-common'; // @public export class EppoJSClient extends EppoClient { // (undocumented) - getAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): string | null; + getBoolAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: boolean): boolean; // (undocumented) - getBoolAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): boolean | null; + getIntegerAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: number): number; // (undocumented) - getJSONStringAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): string | null; + getJSONAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: object): object; // (undocumented) - getNumericAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): number | null; + getNumericAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: number): number; // (undocumented) - getParsedJSONAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): object | null; - // (undocumented) - getStringAssignment(subjectKey: string, flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks): string | null; + getStringAssignment(flagKey: string, subjectKey: string, subjectAttributes: Record, defaultValue: string): string; // (undocumented) static initialized: boolean; // (undocumented) diff --git a/package.json b/package.json index 2920555..e1e9433 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk", - "version": "1.7.3", + "version": "3.0.0", "description": "Eppo SDK for client-side JavaScript applications", "main": "dist/index.js", "files": [ @@ -58,7 +58,7 @@ "xhr-mock": "^2.5.1" }, "dependencies": { - "@eppo/js-client-sdk-common": "2.2.3", + "@eppo/js-client-sdk-common": "3.0.1", "md5": "^2.3.0" } -} +} \ No newline at end of file diff --git a/src/index.spec.ts b/src/index.spec.ts index 2d45c96..aa5c238 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -2,19 +2,24 @@ * @jest-environment jsdom */ -import { HttpClient } from '@eppo/js-client-sdk-common'; -import { POLL_INTERVAL_MS, POLL_JITTER_PCT } from '@eppo/js-client-sdk-common/dist/constants'; -import { IExperimentConfiguration } from '@eppo/js-client-sdk-common/dist/dto/experiment-configuration-dto'; -import { EppoValue } from '@eppo/js-client-sdk-common/dist/eppo_value'; +import { createHash } from 'crypto'; + +import { HttpClient, Flag, VariationType, constants } from '@eppo/js-client-sdk-common'; import * as md5 from 'md5'; import * as td from 'testdouble'; +import { encode } from 'universal-base64'; import mock from 'xhr-mock'; +const { POLL_INTERVAL_MS, POLL_JITTER_PCT } = constants; + import { IAssignmentTestCase, - ValueTestType, readAssignmentTestData, - readMockRacResponse, + readMockUfcResponse, + MOCK_UFC_RESPONSE_FILE, + OBFUSCATED_MOCK_UFC_RESPONSE_FILE, + getTestAssignments, + validateTestAssignments, } from '../test/testHelpers'; import { EppoLocalStorage } from './local-storage'; @@ -22,71 +27,96 @@ import { LocalStorageAssignmentCache } from './local-storage-assignment-cache'; import { IAssignmentLogger, IEppoClient, getInstance, init } from './index'; +const flagEndpoint = /flag-config\/v1\/config*/; + +export function md5Hash(input: string): string { + return createHash('md5').update(input).digest('hex'); +} + +export function base64Encode(input: string): string { + return Buffer.from(input).toString('base64'); +} + describe('EppoJSClient E2E test', () => { let globalClient: IEppoClient; let mockLogger: IAssignmentLogger; - let returnRac = readMockRacResponse; // function so it can be overridden per-test + let returnUfc = readMockUfcResponse; // function so it can be overridden per-test const apiKey = 'dummy'; const baseUrl = 'http://127.0.0.1:4000'; + const flagKey = 'mock-experiment'; - const hashedFlagKey = md5(flagKey); + const obfuscatedFlagKey = md5(flagKey); - const mockExperimentConfig = { - name: hashedFlagKey, + const allocationKey = 'traffic-split'; + const obfuscatedAllocationKey = base64Encode(allocationKey); + + // Configuration for a single flag within the UFC. + const mockUfcFlagConfig: Flag = { + key: obfuscatedFlagKey, enabled: true, - subjectShards: 100, - overrides: {}, - typedOverrides: {}, - rules: [ - { - allocationKey: 'allocation1', - conditions: [] as Array>, + variationType: VariationType.STRING, + variations: { + [base64Encode('control')]: { + key: base64Encode('control'), + value: base64Encode('control'), }, - ], - allocations: { - allocation1: { - percentExposure: 1, - variations: [ + [base64Encode('variant-1')]: { + key: base64Encode('variant-1'), + value: base64Encode('variant-1'), + }, + [base64Encode('variant-2')]: { + key: base64Encode('variant-2'), + value: base64Encode('variant-2'), + }, + }, + allocations: [ + { + key: obfuscatedAllocationKey, + rules: [], + splits: [ { - name: 'control', - value: 'control', - typedValue: 'control', - shardRange: { - start: 0, - end: 34, - }, + variationKey: base64Encode('control'), + shards: [ + { + salt: base64Encode('some-salt'), + ranges: [{ start: 0, end: 3400 }], + }, + ], }, { - name: 'variant-1', - value: 'variant-1', - typedValue: 'variant-1', - shardRange: { - start: 34, - end: 67, - }, + variationKey: base64Encode('variant-1'), + shards: [ + { + salt: base64Encode('some-salt'), + ranges: [{ start: 3400, end: 6700 }], + }, + ], }, { - name: 'variant-2', - value: 'variant-2', - typedValue: 'variant-2', - shardRange: { - start: 67, - end: 100, - }, + variationKey: base64Encode('variant-2'), + shards: [ + { + salt: base64Encode('some-salt'), + ranges: [{ start: 6700, end: 10000 }], + }, + ], }, ], + doLog: true, }, - }, + ], + totalShards: 10000, }; beforeAll(async () => { mock.setup(); - mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { - const rac = returnRac(); - return res.status(200).body(JSON.stringify(rac)); - }); mockLogger = td.object(); + mock.get(flagEndpoint, (_req, res) => { + const ufc = returnUfc(MOCK_UFC_RESPONSE_FILE); + return res.status(200).body(JSON.stringify(ufc)); + }); + globalClient = await init({ apiKey, baseUrl, @@ -95,7 +125,7 @@ describe('EppoJSClient E2E test', () => { }); afterEach(() => { - returnRac = readMockRacResponse; + returnUfc = readMockUfcResponse; globalClient.setLogger(mockLogger); td.reset(); }); @@ -104,72 +134,40 @@ describe('EppoJSClient E2E test', () => { mock.teardown(); }); - it('assigns subject from overrides when experiment is enabled', () => { - td.replace(EppoLocalStorage.prototype, 'get', (key: string) => { - if (key !== hashedFlagKey) { - throw new Error('Unexpected key ' + key); - } - return { - ...mockExperimentConfig, - overrides: { - '1b50f33aef8f681a13f623963da967ed': 'variant-2', - }, - typedOverrides: { - '1b50f33aef8f681a13f623963da967ed': 'variant-2', - }, - }; - }); - - const assignment = globalClient.getAssignment('subject-10', flagKey); - expect(assignment).toEqual('variant-2'); - }); - - it('assigns subject from overrides when experiment is not enabled', () => { - td.replace(EppoLocalStorage.prototype, 'get', (key: string) => { - if (key !== hashedFlagKey) { - throw new Error('Unexpected key ' + key); - } - return { - ...mockExperimentConfig, - overrides: { - '1b50f33aef8f681a13f623963da967ed': 'variant-2', - }, - typedOverrides: { - '1b50f33aef8f681a13f623963da967ed': 'variant-2', - }, - }; - }); - const assignment = globalClient.getAssignment('subject-10', flagKey); - expect(assignment).toEqual('variant-2'); - }); - - it('returns null when experiment config is absent', () => { + it('returns default value when experiment config is absent', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars td.replace(EppoLocalStorage.prototype, 'get', (key: string) => null as null); - const assignment = globalClient.getAssignment('subject-10', flagKey); - expect(assignment).toEqual(null); + const assignment = globalClient.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); + expect(assignment).toEqual('default-value'); }); it('logs variation assignment and experiment key', () => { td.replace(EppoLocalStorage.prototype, 'get', (key: string) => { - if (key !== hashedFlagKey) { + if (key !== obfuscatedFlagKey) { throw new Error('Unexpected key ' + key); } - return mockExperimentConfig; + + return mockUfcFlagConfig; }); const subjectAttributes = { foo: 3 }; globalClient.setLogger(mockLogger); - const assignment = globalClient.getAssignment('subject-10', flagKey, subjectAttributes); - expect(assignment).toEqual('control'); + const assignment = globalClient.getStringAssignment( + flagKey, + 'subject-10', + subjectAttributes, + 'default-value', + ); + + expect(assignment).toEqual('variant-1'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); expect(td.explain(mockLogger?.logAssignment).calls[0]?.args[0].subject).toEqual('subject-10'); expect(td.explain(mockLogger?.logAssignment).calls[0]?.args[0].featureFlag).toEqual(flagKey); expect(td.explain(mockLogger?.logAssignment).calls[0]?.args[0].experiment).toEqual( - `${flagKey}-${mockExperimentConfig?.rules[0]?.allocationKey}`, + `${flagKey}-${allocationKey}`, ); expect(td.explain(mockLogger?.logAssignment).calls[0]?.args[0].allocation).toEqual( - `${mockExperimentConfig?.rules[0]?.allocationKey}`, + `${allocationKey}`, ); }); @@ -177,100 +175,114 @@ describe('EppoJSClient E2E test', () => { const mockLogger = td.object(); td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); td.replace(EppoLocalStorage.prototype, 'get', (key: string) => { - if (key !== hashedFlagKey) { + if (key !== obfuscatedFlagKey) { throw new Error('Unexpected key ' + key); } - return mockExperimentConfig; + return mockUfcFlagConfig; }); const subjectAttributes = { foo: 3 }; globalClient.setLogger(mockLogger); - const assignment = globalClient.getAssignment('subject-10', flagKey, subjectAttributes); - expect(assignment).toEqual('control'); + const assignment = globalClient.getStringAssignment( + flagKey, + 'subject-10', + subjectAttributes, + 'default-value', + ); + expect(assignment).toEqual('variant-1'); }); it('only returns variation if subject matches rules', () => { td.replace(EppoLocalStorage.prototype, 'get', (key: string) => { - if (key !== hashedFlagKey) { + if (key !== obfuscatedFlagKey) { throw new Error('Unexpected key ' + key); } + + // Modified flag with a single rule. return { - ...mockExperimentConfig, - rules: [ + ...mockUfcFlagConfig, + allocations: [ { - allocationKey: 'allocation1', - conditions: [ + ...mockUfcFlagConfig.allocations[0], + rules: [ { - operator: md5('GT'), - attribute: md5('appVersion'), - value: Buffer.from('10', 'utf8').toString('base64'), + conditions: [ + { + attribute: md5('appVersion'), + operator: md5('GT'), + value: encode('10'), + }, + ], }, ], }, ], }; }); - let assignment = globalClient.getAssignment('subject-10', flagKey, { - appVersion: 9, - }); - expect(assignment).toEqual(null); - assignment = globalClient.getAssignment('subject-10', flagKey); - expect(assignment).toEqual(null); - assignment = globalClient.getAssignment('subject-10', flagKey, { - appVersion: 11, - }); - expect(assignment).toEqual('control'); + + let assignment = globalClient.getStringAssignment( + flagKey, + 'subject-10', + { appVersion: 9 }, + 'default-value', + ); + expect(assignment).toEqual('default-value'); + assignment = globalClient.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); + expect(assignment).toEqual('default-value'); + assignment = globalClient.getStringAssignment( + flagKey, + 'subject-10', + { appVersion: 11 }, + 'default-value', + ); + expect(assignment).toEqual('variant-1'); }); - describe('getAssignment', () => { - const testData = readAssignmentTestData(); + describe('UFC Obfuscated Test Cases', () => { + beforeAll(async () => { + mock.setup(); + mock.get(flagEndpoint, (_req, res) => { + const ufc = readMockUfcResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE); + return res.status(200).body(JSON.stringify(ufc)); + }); + + globalClient = await init({ + apiKey, + baseUrl, + assignmentLogger: mockLogger, + }); + }); - it.each(testData)( + afterAll(() => { + mock.teardown(); + }); + + it.each(readAssignmentTestData())( 'test variation assignment splits', - ({ - experiment, - valueType = ValueTestType.StringType, - subjects, - subjectsWithAttributes, - expectedAssignments, - }: IAssignmentTestCase) => { - console.log(`---- Test Case for ${experiment} Experiment ----`); - - const assignments = getAssignmentsWithSubjectAttributes( - subjectsWithAttributes - ? subjectsWithAttributes - : subjects.map((subject) => ({ subjectKey: subject })), - experiment, - valueType, - ); + async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => { + const client = getInstance(); - switch (valueType) { - case ValueTestType.BoolType: { - const boolAssignments = assignments.map((a) => a?.boolValue ?? null); - expect(boolAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.NumericType: { - const numericAssignments = assignments.map((a) => a?.numericValue ?? null); - expect(numericAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.StringType: { - const stringAssignments = assignments.map((a) => a?.stringValue ?? null); - expect(stringAssignments).toEqual(expectedAssignments); - break; - } - case ValueTestType.JSONType: { - const jsonStringAssignments = assignments.map((a) => a?.stringValue ?? null); - expect(jsonStringAssignments).toEqual(expectedAssignments); - break; - } + const typeAssignmentFunctions = { + [VariationType.BOOLEAN]: client.getBoolAssignment.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), + [VariationType.STRING]: client.getStringAssignment.bind(client), + [VariationType.JSON]: client.getJSONAssignment.bind(client), + }; + + const assignmentFn = typeAssignmentFunctions[variationType]; + if (!assignmentFn) { + throw new Error(`Unknown variation type: ${variationType}`); } + + const assignments = getTestAssignments( + { flag, variationType, defaultValue, subjects }, + assignmentFn, + true, + ); + + validateTestAssignments(assignments, flag); }, ); - - it('runs expected number of test cases', () => { - expect(testData.length).toBeGreaterThan(0); - }); }); describe('LocalStorageAssignmentCache', () => { @@ -281,7 +293,7 @@ describe('EppoJSClient E2E test', () => { subjectKey: 'subject-1', flagKey: 'flag-1', allocationKey: 'allocation-1', - variationValue: EppoValue.String('control'), + variationKey: 'control', }), ).toEqual(false); @@ -289,7 +301,7 @@ describe('EppoJSClient E2E test', () => { subjectKey: 'subject-1', flagKey: 'flag-1', allocationKey: 'allocation-1', - variationValue: EppoValue.String('control'), + variationKey: 'control', }); expect( @@ -297,7 +309,7 @@ describe('EppoJSClient E2E test', () => { subjectKey: 'subject-1', flagKey: 'flag-1', allocationKey: 'allocation-1', - variationValue: EppoValue.String('control'), + variationKey: 'control', }), ).toEqual(true); // this key has been logged @@ -306,7 +318,7 @@ describe('EppoJSClient E2E test', () => { subjectKey: 'subject-1', flagKey: 'flag-1', allocationKey: 'allocation-1', - variationValue: EppoValue.String('variant'), + variationKey: 'variant', }); expect( @@ -314,75 +326,19 @@ describe('EppoJSClient E2E test', () => { subjectKey: 'subject-1', flagKey: 'flag-1', allocationKey: 'allocation-1', - variationValue: EppoValue.String('control'), + variationKey: 'control', }), ).toEqual(false); // this key has not been logged }); }); - function getAssignmentsWithSubjectAttributes( - subjectsWithAttributes: { - subjectKey: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record; - }[], - experiment: string, - valueTestType: ValueTestType = ValueTestType.StringType, - ): (EppoValue | null)[] { - return subjectsWithAttributes.map((subject) => { - switch (valueTestType) { - case ValueTestType.BoolType: { - const ba = globalClient.getBoolAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - ); - if (ba === null) return null; - return EppoValue.Bool(ba); - } - case ValueTestType.NumericType: { - const na = globalClient.getNumericAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - ); - if (na === null) return null; - return EppoValue.Numeric(na); - } - case ValueTestType.StringType: { - const sa = globalClient.getStringAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - ); - if (sa === null) return null; - return EppoValue.String(sa); - } - case ValueTestType.JSONType: { - const sa = globalClient.getJSONStringAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - ); - const oa = globalClient.getParsedJSONAssignment( - subject.subjectKey, - experiment, - subject.subjectAttributes, - ); - if (oa == null || sa === null) return null; - return EppoValue.JSON(sa, oa); - } - } - }); - } - describe('initialization options', () => { const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT; const mockConfigResponse = { flags: { - [hashedFlagKey]: mockExperimentConfig, + [obfuscatedFlagKey]: mockUfcFlagConfig, }, - } as unknown as Record; + } as unknown as Record<'flags', Record>; beforeAll(() => { jest.useFakeTimers({ @@ -445,7 +401,7 @@ describe('EppoJSClient E2E test', () => { // Await so it can finish its initialization before this test proceeds const client = await initPromise; expect(callCount).toBe(2); - expect(client.getStringAssignment('subject', flagKey)).toBe('control'); + expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control'); // By default, no more calls await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 10); @@ -468,7 +424,7 @@ describe('EppoJSClient E2E test', () => { pollAfterSuccessfulInitialization: true, }); expect(callCount).toBe(1); - expect(client.getStringAssignment('subject', flagKey)).toBe('control'); + expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control'); // Advance timers mid-init to allow retrying await jest.advanceTimersByTimeAsync(maxRetryDelay); @@ -499,9 +455,11 @@ describe('EppoJSClient E2E test', () => { expect(callCount).toBe(1); - // Assignments resolve to null + // Assignments resolve to default. const client = getInstance(); - expect(client.getStringAssignment('subject', flagKey)).toBeNull(); + expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe( + 'default-value', + ); // Expect no further configuration requests await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS); @@ -537,8 +495,10 @@ describe('EppoJSClient E2E test', () => { const client = await initPromise; expect(callCount).toBe(2); - // Initial assignments resolve to null - expect(client.getStringAssignment('subject', flagKey)).toBeNull(); + // Initial assignments resolve to be the default + expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe( + 'default-value', + ); await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS); @@ -546,7 +506,7 @@ describe('EppoJSClient E2E test', () => { expect(callCount).toBe(3); // Assignments now working - expect(client.getStringAssignment('subject', flagKey)).toBe('control'); + expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control'); }); describe('With reloaded index module', () => { @@ -554,7 +514,7 @@ describe('EppoJSClient E2E test', () => { let init: Function; // eslint-disable-next-line @typescript-eslint/ban-types let getInstance: Function; - beforeEach(() => { + beforeEach(async () => { jest.isolateModules(() => { // Isolate and re-require so that the static instance is reset to it's default state // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -562,22 +522,39 @@ describe('EppoJSClient E2E test', () => { init = reloadedModule.init; getInstance = reloadedModule.getInstance; }); + + mock.setup(); + mockLogger = td.object(); + mock.get(flagEndpoint, (_req, res) => { + const ufc = returnUfc(MOCK_UFC_RESPONSE_FILE); + return res.status(200).body(JSON.stringify(ufc)); + }); + + globalClient = await init({ + apiKey, + baseUrl, + assignmentLogger: mockLogger, + }); }); it('returns empty assignments pre-initialization by default', async () => { - returnRac = () => mockConfigResponse; + returnUfc = () => mockConfigResponse; const client = getInstance(); - expect(client.getStringAssignment('subject', flagKey)).toBeNull(); + expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toBe( + 'default-value', + ); // don't await init({ apiKey, baseUrl, assignmentLogger: mockLogger, }); - expect(client.getStringAssignment('subject', flagKey)).toBeNull(); + expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe( + 'default-value', + ); // Advance time so a poll happened and check again await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS); - expect(client.getStringAssignment('subject', flagKey)).toBe('control'); + expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control'); }); }); }); diff --git a/src/index.ts b/src/index.ts index d66fe95..2309e0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,7 @@ import { validation, IEppoClient, EppoClient, - IAssignmentHooks, - ExperimentConfigurationRequestParameters, + FlagConfigurationRequestParameters, } from '@eppo/js-client-sdk-common'; import { EppoLocalStorage } from './local-storage'; @@ -76,91 +75,57 @@ const localStorage = new EppoLocalStorage(); * @public */ export class EppoJSClient extends EppoClient { - public static instance: EppoJSClient = new EppoJSClient(localStorage); + public static instance: EppoJSClient = new EppoJSClient(localStorage, undefined, true); public static initialized = false; - public getAssignment( - subjectKey: string, - flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record, - assignmentHooks?: IAssignmentHooks, - ): string | null { - EppoJSClient.getAssignmentInitializationCheck(); - return super.getAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks, true); - } - public getStringAssignment( - subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record, - assignmentHooks?: IAssignmentHooks, - ): string | null { + subjectKey: string, + subjectAttributes: Record, + defaultValue: string, + ): string { EppoJSClient.getAssignmentInitializationCheck(); - return super.getStringAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks, true); + return super.getStringAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } public getBoolAssignment( - subjectKey: string, flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record, - assignmentHooks?: IAssignmentHooks, - ): boolean | null { + subjectKey: string, + subjectAttributes: Record, + defaultValue: boolean, + ): boolean { EppoJSClient.getAssignmentInitializationCheck(); - return super.getBoolAssignment(subjectKey, flagKey, subjectAttributes, assignmentHooks, true); + return super.getBoolAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } - public getNumericAssignment( - subjectKey: string, + public getIntegerAssignment( flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record, - assignmentHooks?: IAssignmentHooks, - ): number | null { + subjectKey: string, + subjectAttributes: Record, + defaultValue: number, + ): number { EppoJSClient.getAssignmentInitializationCheck(); - return super.getNumericAssignment( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - true, - ); + return super.getIntegerAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } - public getJSONStringAssignment( - subjectKey: string, + public getNumericAssignment( flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record, - assignmentHooks?: IAssignmentHooks, - ): string | null { + subjectKey: string, + subjectAttributes: Record, + defaultValue: number, + ): number { EppoJSClient.getAssignmentInitializationCheck(); - return super.getJSONStringAssignment( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - true, - ); + return super.getNumericAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } - public getParsedJSONAssignment( - subjectKey: string, + public getJSONAssignment( flagKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectAttributes?: Record, - assignmentHooks?: IAssignmentHooks, - ): object | null { + subjectKey: string, + subjectAttributes: Record, + defaultValue: object, + ): object { EppoJSClient.getAssignmentInitializationCheck(); - return super.getParsedJSONAssignment( - subjectKey, - flagKey, - subjectAttributes, - assignmentHooks, - true, - ); + return super.getJSONAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); } private static getAssignmentInitializationCheck() { @@ -184,7 +149,7 @@ export async function init(config: IClientConfig): Promise { EppoJSClient.instance.stopPolling(); } - const requestConfiguration: ExperimentConfigurationRequestParameters = { + const requestConfiguration: FlagConfigurationRequestParameters = { apiKey: config.apiKey, sdkName, sdkVersion, @@ -206,7 +171,7 @@ export async function init(config: IClientConfig): Promise { await EppoJSClient.instance.fetchFlagConfigurations(); } catch (error) { console.warn( - 'Eppo SDK encountered an error initializing, assignment calls will return null and not be logged' + + 'Eppo SDK encountered an error initializing, assignment calls will return the default value and not be logged' + (config.pollAfterFailedInitialization ? ' until an experiment configuration is successfully retrieved' : ''), diff --git a/src/local-storage.spec.ts b/src/local-storage.spec.ts index 5b38d7b..7b7c98b 100644 --- a/src/local-storage.spec.ts +++ b/src/local-storage.spec.ts @@ -43,5 +43,10 @@ describe('EppoLocalStorage', () => { expect(storage.get('key1')).toEqual(config1); expect(storage.get('key2')).toEqual(config2); }); + + it('returns a list of keys', () => { + storage.setEntries({ key1: config1, key2: config2 }); + expect(storage.getKeys()).toEqual(['key1', 'key2']); + }); }); }); diff --git a/src/local-storage.ts b/src/local-storage.ts index 565bac3..1312bce 100644 --- a/src/local-storage.ts +++ b/src/local-storage.ts @@ -7,6 +7,10 @@ export class EppoLocalStorage { } } + public isInitialized(): boolean { + return hasWindowLocalStorage(); + } + public get(key: string): T { if (hasWindowLocalStorage()) { const serializedEntry = window.localStorage.getItem(key); @@ -17,6 +21,13 @@ export class EppoLocalStorage { return null; } + public getKeys(): string[] { + if (hasWindowLocalStorage()) { + return Object.keys(window.localStorage); + } + return null; + } + public setEntries(entries: Record) { if (hasWindowLocalStorage()) { Object.entries(entries).forEach(([key, val]) => { diff --git a/test/testHelpers.ts b/test/testHelpers.ts index f5f4661..9f0e538 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -1,11 +1,12 @@ import * as fs from 'fs'; -import { IExperimentConfiguration } from '@eppo/js-client-sdk-common/dist/dto/experiment-configuration-dto'; -import { IVariation } from '@eppo/js-client-sdk-common/dist/dto/variation-dto'; +import { Flag, VariationType, AttributeType } from '@eppo/js-client-sdk-common'; -export const TEST_DATA_DIR = './test/data/'; -export const ASSIGNMENT_TEST_DATA_DIR = TEST_DATA_DIR + 'assignment-v2/'; -export const MOCK_RAC_RESPONSE_FILE = 'rac-experiments-v3-obfuscated.json'; +export const TEST_DATA_DIR = './test/data/ufc/'; +export const ASSIGNMENT_TEST_DATA_DIR = TEST_DATA_DIR + 'tests/'; +const MOCK_UFC_FILENAME = 'flags-v1'; +export const MOCK_UFC_RESPONSE_FILE = `${MOCK_UFC_FILENAME}.json`; +export const OBFUSCATED_MOCK_UFC_RESPONSE_FILE = `${MOCK_UFC_FILENAME}-obfuscated.json`; export enum ValueTestType { BoolType = 'boolean', @@ -14,19 +15,23 @@ export enum ValueTestType { JSONType = 'json', } +export interface SubjectTestCase { + subjectKey: string; + subjectAttributes: Record; + assignment: string | number | boolean | object; +} + export interface IAssignmentTestCase { - experiment: string; - valueType: ValueTestType; - percentExposure: number; - variations: IVariation[]; - subjects: string[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subjectsWithAttributes: { subjectKey: string; subjectAttributes: Record }[]; - expectedAssignments: string[]; + flag: string; + variationType: VariationType; + defaultValue: string | number | boolean | object; + subjects: SubjectTestCase[]; } -export function readMockRacResponse(): Record { - return JSON.parse(fs.readFileSync(TEST_DATA_DIR + MOCK_RAC_RESPONSE_FILE, 'utf-8')); +export function readMockUfcResponse(filename: string): { + flags: Record; +} { + return JSON.parse(fs.readFileSync(TEST_DATA_DIR + filename, 'utf-8')); } export function readAssignmentTestData(): IAssignmentTestCase[] { @@ -38,3 +43,48 @@ export function readAssignmentTestData(): IAssignmentTestCase[] { }); return testCaseData; } + +export function getTestAssignments( + testCase: IAssignmentTestCase, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assignmentFn: any, + obfuscated = false, +): { subject: SubjectTestCase; assignment: string | boolean | number | object }[] { + const assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | null | object; + }[] = []; + for (const subject of testCase.subjects) { + const assignment = assignmentFn( + testCase.flag, + subject.subjectKey, + subject.subjectAttributes, + testCase.defaultValue, + obfuscated, + ); + assignments.push({ subject: subject, assignment: assignment }); + } + return assignments; +} + +export function validateTestAssignments( + assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | object; + }[], + flag: string, +) { + for (const { subject, assignment } of assignments) { + if (typeof assignment !== 'object') { + // the expect works well for objects, but this comparison does not + if (assignment !== subject.assignment) { + throw new Error( + `subject ${ + subject.subjectKey + } was assigned ${assignment?.toString()} when expected ${subject.assignment?.toString()} for flag ${flag}`, + ); + } + } + expect(subject.assignment).toEqual(assignment); + } +} diff --git a/yarn.lock b/yarn.lock index 4b97ff3..a4a5213 100644 --- a/yarn.lock +++ b/yarn.lock @@ -380,10 +380,10 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@eppo/js-client-sdk-common@2.2.3": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@eppo/js-client-sdk-common/-/js-client-sdk-common-2.2.3.tgz#004cebb02bd132d7e02591513dffc0dbb0436289" - integrity sha512-yrqA6F3TbSwUVaqTcAvfX+5rKs49eXSptqJf68jq6ZIoSFWo3onfLmJdOZ4cu3AEG3DrXxvzUmykpmtSimxaHQ== +"@eppo/js-client-sdk-common@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@eppo/js-client-sdk-common/-/js-client-sdk-common-3.0.1.tgz#5bebb7f3983836fe1a1fabbfcf282a2050a03278" + integrity sha512-WZDsRTxzzxjw91eU2qiwxQNNl6LhJsmTUBH4exDPt6FPHEwgSFFkqzfu0cyTOqcqpaeFgZ72tYuPZD5ev7AoaA== dependencies: axios "^1.6.0" md5 "^2.3.0"