Skip to content

Commit eeb62dc

Browse files
committed
merge main
2 parents 4fc8fef + 9454e89 commit eeb62dc

12 files changed

+659
-307
lines changed

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
access: public
2020
- name: Upload npm debug log
2121
if: failure() # This step will run only if the previous steps failed
22-
uses: actions/upload-artifact@v2
22+
uses: actions/upload-artifact@v4
2323
with:
2424
name: npm-debug-logs
2525
path: /home/runner/.npm/_logs/*.log

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "4.0.2",
3+
"version": "4.2.0",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [

src/cache/abstract-assignment-cache.spec.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from './abstract-assignment-cache';
66

77
describe('NonExpiringInMemoryAssignmentCache', () => {
8-
it('read and write entries', () => {
8+
it('read and write variation entries', () => {
99
const cache = new NonExpiringInMemoryAssignmentCache();
1010
const key1 = { subjectKey: 'a', flagKey: 'b', allocationKey: 'c', variationKey: 'd' };
1111
const key2 = { subjectKey: '1', flagKey: '2', allocationKey: '3', variationKey: '4' };
@@ -14,7 +14,7 @@ describe('NonExpiringInMemoryAssignmentCache', () => {
1414
expect(cache.has(key2)).toBeFalsy();
1515
cache.set(key2);
1616
expect(cache.has(key2)).toBeTruthy();
17-
// this makes an assumption about the internal implementation of the cache, which is not ideal
17+
// this makes an assumption about the internal implementation of the cache, which is not ideal,
1818
// but it's the only way to test the cache without exposing the internal state
1919
expect(Array.from(cache.entries())).toEqual([
2020
[assignmentCacheKeyToString(key1), assignmentCacheValueToString(key1)],
@@ -24,4 +24,24 @@ describe('NonExpiringInMemoryAssignmentCache', () => {
2424
expect(cache.has({ ...key1, allocationKey: 'c1' })).toBeFalsy();
2525
expect(cache.has({ ...key2, variationKey: 'd1' })).toBeFalsy();
2626
});
27+
28+
it('read and write bandit entries', () => {
29+
const cache = new NonExpiringInMemoryAssignmentCache();
30+
const key1 = { subjectKey: 'a', flagKey: 'b', banditKey: 'c', actionKey: 'd' };
31+
const key2 = { subjectKey: '1', flagKey: '2', banditKey: '3', actionKey: '4' };
32+
cache.set(key1);
33+
expect(cache.has(key1)).toBeTruthy();
34+
expect(cache.has(key2)).toBeFalsy();
35+
cache.set(key2);
36+
expect(cache.has(key2)).toBeTruthy();
37+
// this makes an assumption about the internal implementation of the cache, which is not ideal,
38+
// but it's the only way to test the cache without exposing the internal state
39+
expect(Array.from(cache.entries())).toEqual([
40+
[assignmentCacheKeyToString(key1), assignmentCacheValueToString(key1)],
41+
[assignmentCacheKeyToString(key2), assignmentCacheValueToString(key2)],
42+
]);
43+
44+
expect(cache.has({ ...key1, banditKey: 'c1' })).toBeFalsy();
45+
expect(cache.has({ ...key2, actionKey: 'd1' })).toBeFalsy();
46+
});
2747
});

src/cache/abstract-assignment-cache.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,37 @@ import { getMD5Hash } from '../obfuscation';
22

33
import { LRUCache } from './lru-cache';
44

5-
export type AssignmentCacheValue = {
6-
allocationKey: string;
7-
variationKey: string;
8-
};
9-
5+
/**
6+
* Assignment cache keys are only on the subject and flag level, while the entire value is used
7+
* for uniqueness checking. This way that if an assigned variation or bandit action changes for a
8+
* flag, it evicts the old one. Then, if an older assignment is later reassigned, it will be treated
9+
* as new.
10+
*/
1011
export type AssignmentCacheKey = {
1112
subjectKey: string;
1213
flagKey: string;
1314
};
1415

16+
export type CacheKeyPair<T extends string, U extends string> = {
17+
[K in T]: string;
18+
} & {
19+
[K in U]: string;
20+
};
21+
22+
type VariationCacheValue = CacheKeyPair<'allocationKey', 'variationKey'>;
23+
type BanditCacheValue = CacheKeyPair<'banditKey', 'actionKey'>;
24+
export type AssignmentCacheValue = VariationCacheValue | BanditCacheValue;
25+
1526
export type AssignmentCacheEntry = AssignmentCacheKey & AssignmentCacheValue;
1627

1728
/** Converts an {@link AssignmentCacheKey} to a string. */
1829
export function assignmentCacheKeyToString({ subjectKey, flagKey }: AssignmentCacheKey): string {
1930
return getMD5Hash([subjectKey, flagKey].join(';'));
2031
}
2132

22-
export function assignmentCacheValueToString({
23-
allocationKey,
24-
variationKey,
25-
}: AssignmentCacheValue): string {
26-
return getMD5Hash([allocationKey, variationKey].join(';'));
33+
/** Converts an {@link AssignmentCacheValue} to a string. */
34+
export function assignmentCacheValueToString(cacheValue: AssignmentCacheValue): string {
35+
return getMD5Hash(Object.values(cacheValue).join(';'));
2736
}
2837

2938
export interface AsyncMap<K, V> {

src/client/eppo-client-assignment-details.spec.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,13 @@ import {
55
MOCK_UFC_RESPONSE_FILE,
66
readMockUFCResponse,
77
} from '../../test/testHelpers';
8-
import ApiEndpoints from '../api-endpoints';
9-
import ConfigurationRequestor from '../configuration-requestor';
10-
import { IConfigurationStore } from '../configuration-store/configuration-store';
118
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
129
import { AllocationEvaluationCode } from '../flag-evaluation-details-builder';
13-
import FetchHttpClient from '../http-client';
1410
import { Flag, ObfuscatedFlag, Variation, VariationType } from '../interfaces';
1511
import { OperatorType } from '../rules';
1612

1713
import EppoClient, { IAssignmentDetails } from './eppo-client';
18-
19-
async function init(configurationStore: IConfigurationStore<Flag | ObfuscatedFlag>) {
20-
const apiEndpoints = new ApiEndpoints({
21-
baseUrl: 'http://127.0.0.1:4000',
22-
queryParams: {
23-
apiKey: 'dummy',
24-
sdkName: 'js-client-sdk-common',
25-
sdkVersion: '1.0.0',
26-
},
27-
});
28-
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
29-
const configurationRequestor = new ConfigurationRequestor(
30-
httpClient,
31-
configurationStore,
32-
null,
33-
null,
34-
);
35-
await configurationRequestor.fetchAndStoreConfigurations();
36-
}
14+
import { initConfiguration } from './test-utils';
3715

3816
describe('EppoClient get*AssignmentDetails', () => {
3917
const testStart = Date.now();
@@ -50,7 +28,7 @@ describe('EppoClient get*AssignmentDetails', () => {
5028
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
5129

5230
beforeAll(async () => {
53-
await init(storage);
31+
await initConfiguration(storage);
5432
});
5533

5634
it('should set the details for a matched rule', () => {
@@ -307,7 +285,7 @@ describe('EppoClient get*AssignmentDetails', () => {
307285
});
308286
}) as jest.Mock;
309287

310-
await init(storage);
288+
await initConfiguration(storage);
311289
});
312290

313291
afterAll(() => {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { MOCK_UFC_RESPONSE_FILE, readMockUFCResponse } from '../../test/testHelpers';
2+
import * as applicationLogger from '../application-logger';
3+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
4+
import { Flag, ObfuscatedFlag } from '../interfaces';
5+
6+
import EppoClient, { IContainerExperiment } from './eppo-client';
7+
import { initConfiguration } from './test-utils';
8+
9+
type Container = { name: string };
10+
11+
describe('getExperimentContainerEntry', () => {
12+
global.fetch = jest.fn(() => {
13+
const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE);
14+
return Promise.resolve({
15+
ok: true,
16+
status: 200,
17+
json: () => Promise.resolve(ufc),
18+
});
19+
}) as jest.Mock;
20+
21+
const controlContainer: Container = { name: 'Control Container' };
22+
const treatment1Container: Container = { name: 'Treatment Variation 1 Container' };
23+
const treatment2Container: Container = { name: 'Treatment Variation 2 Container' };
24+
const treatment3Container: Container = { name: 'Treatment Variation 3 Container' };
25+
26+
let client: EppoClient;
27+
let flagExperiment: IContainerExperiment<Container>;
28+
let getStringAssignmentSpy: jest.SpyInstance;
29+
let loggerWarnSpy: jest.SpyInstance;
30+
31+
beforeEach(async () => {
32+
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
33+
await initConfiguration(storage);
34+
client = new EppoClient(storage);
35+
client.setIsGracefulFailureMode(true);
36+
flagExperiment = {
37+
flagKey: 'my-key',
38+
controlVariationEntry: controlContainer,
39+
treatmentVariationEntries: [treatment1Container, treatment2Container, treatment3Container],
40+
};
41+
getStringAssignmentSpy = jest.spyOn(client, 'getStringAssignment');
42+
loggerWarnSpy = jest.spyOn(applicationLogger.logger, 'warn');
43+
});
44+
45+
afterAll(() => {
46+
getStringAssignmentSpy.mockRestore();
47+
loggerWarnSpy.mockRestore();
48+
});
49+
50+
it('should return the right container when a treatment variation is assigned', async () => {
51+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('treatment-2');
52+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
53+
treatment2Container,
54+
);
55+
56+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('treatment-3');
57+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
58+
treatment3Container,
59+
);
60+
});
61+
62+
it('should return the right container when control is assigned', async () => {
63+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('control');
64+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
65+
controlContainer,
66+
);
67+
expect(loggerWarnSpy).not.toHaveBeenCalled();
68+
});
69+
70+
it('should default to the control container if a treatment number cannot be parsed', async () => {
71+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('treatment-asdf');
72+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
73+
controlContainer,
74+
);
75+
expect(loggerWarnSpy).toHaveBeenCalled();
76+
});
77+
78+
it('should default to the control container if an unknown variation is assigned', async () => {
79+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('adsfsadfsadf');
80+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
81+
controlContainer,
82+
);
83+
expect(loggerWarnSpy).toHaveBeenCalled();
84+
});
85+
86+
it('should default to the control container if an out-of-bounds treatment variation is assigned', async () => {
87+
jest.spyOn(client, 'getStringAssignment').mockReturnValue('treatment-9');
88+
expect(client.getExperimentContainerEntry(flagExperiment, 'subject-key', {})).toEqual(
89+
controlContainer,
90+
);
91+
expect(loggerWarnSpy).toHaveBeenCalled();
92+
});
93+
});

0 commit comments

Comments
 (0)