From be8cc13d7cd1c23c7fed5773b720056e57b2f268 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 20:08:50 -0500 Subject: [PATCH 1/5] Add getFlagsConfiguration() to export flag configuration as JSON This function returns the current flags configuration as a JSON string that can be used to bootstrap another SDK instance. It includes flags, format, createdAt, environment, and banditReferences when available. Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 59 ++++++++++++++++++++++++- src/index.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 908f601..1c8af55 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -32,7 +32,14 @@ import { import * as util from './util/index'; -import { getInstance, IAssignmentEvent, IAssignmentLogger, init, NO_OP_EVENT_DISPATCHER } from '.'; +import { + getFlagsConfiguration, + getInstance, + IAssignmentEvent, + IAssignmentLogger, + init, + NO_OP_EVENT_DISPATCHER, +} from '.'; import SpyInstance = jest.SpyInstance; @@ -736,3 +743,53 @@ describe('EppoClient E2E test', () => { }); }); }); + +describe('getFlagsConfiguration', () => { + it('returns configuration JSON after init', async () => { + await init({ + apiKey: 'dummy', + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + }); + + const exportedConfig = getFlagsConfiguration(); + expect(exportedConfig).not.toBeNull(); + + const parsed = JSON.parse(exportedConfig ?? ''); + expect(parsed.flags).toBeDefined(); + expect(parsed.format).toBe('SERVER'); + }); + + it('includes flags from the API response', async () => { + await init({ + apiKey: 'dummy', + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + }); + + const exportedConfig = getFlagsConfiguration(); + expect(exportedConfig).not.toBeNull(); + + const parsed = JSON.parse(exportedConfig ?? ''); + // The mock server returns flags, so we should have some + expect(Object.keys(parsed.flags).length).toBeGreaterThan(0); + }); + + it('includes environment when available', async () => { + await init({ + apiKey: 'dummy', + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + }); + + const exportedConfig = getFlagsConfiguration(); + expect(exportedConfig).not.toBeNull(); + + const parsed = JSON.parse(exportedConfig ?? ''); + // Environment may or may not be set depending on mock data + // Just verify the structure is correct + if (parsed.environment) { + expect(parsed.environment.name).toBeDefined(); + } + }); +}); diff --git a/src/index.ts b/src/index.ts index f0c3dc8..6295514 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,9 @@ export { export { IClientConfig }; let clientInstance: EppoClient; +let flagConfigurationStore: MemoryOnlyConfigurationStore; +let banditVariationConfigurationStore: MemoryOnlyConfigurationStore; +let banditModelConfigurationStore: MemoryOnlyConfigurationStore; export const NO_OP_EVENT_DISPATCHER: EventDispatcher = { // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -86,9 +89,9 @@ export async function init(config: IClientConfig): Promise { throwOnFailedInitialization, }; - const flagConfigurationStore = new MemoryOnlyConfigurationStore(); - const banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); - const banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); + flagConfigurationStore = new MemoryOnlyConfigurationStore(); + banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); + banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); const eventDispatcher = newEventDispatcher(apiKey, eventTracking); clientInstance = new EppoClient({ @@ -144,6 +147,107 @@ export function getInstance(): EppoClient { return clientInstance; } +/** + * Returns the current flags configuration as a JSON string. + * This can be used to bootstrap another SDK instance using offlineInit(). + * + * @returns JSON string containing the flags configuration, or null if not initialized + * @public + */ +export function getFlagsConfiguration(): string | null { + if (!flagConfigurationStore) { + return null; + } + + const flags = flagConfigurationStore.entries(); + const format = flagConfigurationStore.getFormat(); + const createdAt = flagConfigurationStore.getConfigPublishedAt(); + const environment = flagConfigurationStore.getEnvironment(); + + const configuration: { + createdAt?: string; + format?: string; + environment?: { name: string }; + flags: Record; + banditReferences?: Record< + string, + { + modelVersion: string; + flagVariations: BanditVariation[]; + } + >; + } = { + flags, + }; + + if (createdAt) { + configuration.createdAt = createdAt; + } + if (format) { + configuration.format = format; + } + if (environment) { + configuration.environment = environment; + } + + const banditReferences = reconstructBanditReferences(); + if (banditReferences) { + configuration.banditReferences = banditReferences; + } + + return JSON.stringify(configuration); +} + +/** + * Reconstructs banditReferences from stored variations and parameters. + * The variations are stored indexed by flag key, so we need to re-pivot them + * back to being indexed by bandit key for export. + */ +function reconstructBanditReferences(): Record< + string, + { modelVersion: string; flagVariations: BanditVariation[] } +> | null { + if (!banditVariationConfigurationStore || !banditModelConfigurationStore) { + return null; + } + + const variationsByFlagKey = banditVariationConfigurationStore.entries(); + const banditParameters = banditModelConfigurationStore.entries(); + + // Flatten all variations and group by bandit key + const variationsByBanditKey: Record = {}; + for (const variations of Object.values(variationsByFlagKey)) { + for (const variation of variations) { + const banditKey = variation.key; + if (!variationsByBanditKey[banditKey]) { + variationsByBanditKey[banditKey] = []; + } + variationsByBanditKey[banditKey].push(variation); + } + } + + // Build banditReferences with model versions + const banditReferences: Record< + string, + { modelVersion: string; flagVariations: BanditVariation[] } + > = {}; + for (const [banditKey, variations] of Object.entries(variationsByBanditKey)) { + const params = banditParameters[banditKey]; + if (params) { + banditReferences[banditKey] = { + modelVersion: params.modelVersion, + flagVariations: variations, + }; + } + } + + if (Object.keys(banditReferences).length === 0) { + return null; + } + + return banditReferences; +} + function newEventDispatcher( sdkKey: string, config: IClientConfig['eventTracking'] = {}, From e6fcbfdb6d5fe3b8e28b0f6ac60c6d7eb9b87060 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:03:55 -0500 Subject: [PATCH 2/5] Fix test isolation issues in getFlagsConfiguration tests - Remove jest.resetModules() from read-only file system tests (not needed) - Add td.reset() afterEach in initialization errors tests to clean up mocks - Move getFlagsConfiguration tests inside main EppoClient E2E test block - Consolidate getFlagsConfiguration tests into single test for better isolation Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 71 ++++++++++++++++------------------------------- 1 file changed, 24 insertions(+), 47 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 1c8af55..b13d294 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -508,6 +508,10 @@ describe('EppoClient E2E test', () => { }, }; + afterEach(() => { + td.reset(); + }); + it('retries initial configuration request before resolving', async () => { td.replace(HttpClient.prototype, 'getUniversalFlagConfiguration'); let callCount = 0; @@ -642,8 +646,6 @@ describe('EppoClient E2E test', () => { let isReadOnlyFsSpy: SpyInstance; beforeEach(() => { - // Reset the module before each test - jest.resetModules(); // Create a spy on isReadOnlyFs that we can mock isReadOnlyFsSpy = jest.spyOn(util, 'isReadOnlyFs'); }); @@ -742,54 +744,29 @@ describe('EppoClient E2E test', () => { expect(configurationRequestParameters.pollAfterSuccessfulInitialization).toBe(false); }); }); -}); - -describe('getFlagsConfiguration', () => { - it('returns configuration JSON after init', async () => { - await init({ - apiKey: 'dummy', - baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, - assignmentLogger: { logAssignment: jest.fn() }, - }); - const exportedConfig = getFlagsConfiguration(); - expect(exportedConfig).not.toBeNull(); - - const parsed = JSON.parse(exportedConfig ?? ''); - expect(parsed.flags).toBeDefined(); - expect(parsed.format).toBe('SERVER'); - }); - - it('includes flags from the API response', async () => { - await init({ - apiKey: 'dummy', - baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, - assignmentLogger: { logAssignment: jest.fn() }, - }); - - const exportedConfig = getFlagsConfiguration(); - expect(exportedConfig).not.toBeNull(); + describe('getFlagsConfiguration', () => { + it('returns configuration JSON with flags and format after init', async () => { + const client = await init({ + apiKey: 'dummy', + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + }); - const parsed = JSON.parse(exportedConfig ?? ''); - // The mock server returns flags, so we should have some - expect(Object.keys(parsed.flags).length).toBeGreaterThan(0); - }); + const exportedConfig = getFlagsConfiguration(); + expect(exportedConfig).not.toBeNull(); + + const parsed = JSON.parse(exportedConfig ?? ''); + expect(parsed.flags).toBeDefined(); + expect(parsed.format).toBe('SERVER'); + // The mock server returns flags, so we should have some + expect(Object.keys(parsed.flags).length).toBeGreaterThan(0); + // Environment may or may not be set depending on mock data + if (parsed.environment) { + expect(parsed.environment.name).toBeDefined(); + } - it('includes environment when available', async () => { - await init({ - apiKey: 'dummy', - baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, - assignmentLogger: { logAssignment: jest.fn() }, + client.stopPolling(); }); - - const exportedConfig = getFlagsConfiguration(); - expect(exportedConfig).not.toBeNull(); - - const parsed = JSON.parse(exportedConfig ?? ''); - // Environment may or may not be set depending on mock data - // Just verify the structure is correct - if (parsed.environment) { - expect(parsed.environment.name).toBeDefined(); - } }); }); From 1b804de968a0ad38570372f7b81ab05581cf5b0e Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:10:05 -0500 Subject: [PATCH 3/5] Improve getFlagsConfiguration test cleanup pattern Use afterAll with null check for more robust client cleanup. Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index b13d294..529b46c 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -746,8 +746,16 @@ describe('EppoClient E2E test', () => { }); describe('getFlagsConfiguration', () => { + let client: EppoClient | null = null; + + afterAll(() => { + if (client) { + client.stopPolling(); + } + }); + it('returns configuration JSON with flags and format after init', async () => { - const client = await init({ + client = await init({ apiKey: 'dummy', baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, assignmentLogger: { logAssignment: jest.fn() }, @@ -765,8 +773,6 @@ describe('EppoClient E2E test', () => { if (parsed.environment) { expect(parsed.environment.name).toBeDefined(); } - - client.stopPolling(); }); }); }); From d777a2c51e322a533caadbf33418120bdc391e4d Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:20:17 -0500 Subject: [PATCH 4/5] Improve getFlagsConfiguration test with specific assertions - Verify exact metadata (format, createdAt, environment) - Check exact number of flags (22) from flags-v1.json - Thoroughly test new-user-onboarding flag structure including: - All 6 variations - All 4 allocations with their rules and conditions - Various operators (MATCHES, NOT_ONE_OF, ONE_OF) - Extra logging configuration Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 75 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 529b46c..64a1075 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -754,7 +754,7 @@ describe('EppoClient E2E test', () => { } }); - it('returns configuration JSON with flags and format after init', async () => { + it('returns configuration JSON matching flags-v1.json structure', async () => { client = await init({ apiKey: 'dummy', baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, @@ -765,14 +765,73 @@ describe('EppoClient E2E test', () => { expect(exportedConfig).not.toBeNull(); const parsed = JSON.parse(exportedConfig ?? ''); - expect(parsed.flags).toBeDefined(); + + // Verify top-level metadata expect(parsed.format).toBe('SERVER'); - // The mock server returns flags, so we should have some - expect(Object.keys(parsed.flags).length).toBeGreaterThan(0); - // Environment may or may not be set depending on mock data - if (parsed.environment) { - expect(parsed.environment.name).toBeDefined(); - } + expect(parsed.createdAt).toBe('2024-04-17T19:40:53.716Z'); + expect(parsed.environment).toEqual({ name: 'Test' }); + + // Verify exact number of flags from flags-v1.json + expect(Object.keys(parsed.flags).length).toBe(22); + + // Verify a complex flag with rules and conditions: new-user-onboarding + const flag = parsed.flags['new-user-onboarding']; + expect(flag).toBeDefined(); + expect(flag.key).toBe('new-user-onboarding'); + expect(flag.enabled).toBe(true); + expect(flag.variationType).toBe('STRING'); + expect(flag.totalShards).toBe(10000); + + // Verify variations + expect(Object.keys(flag.variations).length).toBe(6); + expect(flag.variations.control).toEqual({ key: 'control', value: 'control' }); + expect(flag.variations.red).toEqual({ key: 'red', value: 'red' }); + expect(flag.variations.blue).toEqual({ key: 'blue', value: 'blue' }); + expect(flag.variations.green).toEqual({ key: 'green', value: 'green' }); + expect(flag.variations.yellow).toEqual({ key: 'yellow', value: 'yellow' }); + expect(flag.variations.purple).toEqual({ key: 'purple', value: 'purple' }); + + // Verify allocations structure + expect(flag.allocations.length).toBe(4); + + // First allocation: "id rule" with MATCHES condition + const idRuleAlloc = flag.allocations[0]; + expect(idRuleAlloc.key).toBe('id rule'); + expect(idRuleAlloc.doLog).toBe(false); + expect(idRuleAlloc.rules.length).toBe(1); + expect(idRuleAlloc.rules[0].conditions.length).toBe(1); + expect(idRuleAlloc.rules[0].conditions[0]).toEqual({ + attribute: 'id', + operator: 'MATCHES', + value: 'zach', + }); + expect(idRuleAlloc.splits[0].variationKey).toBe('purple'); + + // Second allocation: "internal users" with MATCHES condition + const internalUsersAlloc = flag.allocations[1]; + expect(internalUsersAlloc.key).toBe('internal users'); + expect(internalUsersAlloc.rules[0].conditions[0]).toEqual({ + attribute: 'email', + operator: 'MATCHES', + value: '@mycompany.com', + }); + + // Third allocation: "experiment" with NOT_ONE_OF condition and shards + const experimentAlloc = flag.allocations[2]; + expect(experimentAlloc.key).toBe('experiment'); + expect(experimentAlloc.doLog).toBe(true); + expect(experimentAlloc.rules[0].conditions[0].operator).toBe('NOT_ONE_OF'); + expect(experimentAlloc.rules[0].conditions[0].value).toEqual(['US', 'Canada', 'Mexico']); + expect(experimentAlloc.splits.length).toBe(3); // control, red, yellow + + // Fourth allocation: "rollout" with ONE_OF condition and extraLogging + const rolloutAlloc = flag.allocations[3]; + expect(rolloutAlloc.key).toBe('rollout'); + expect(rolloutAlloc.rules[0].conditions[0].operator).toBe('ONE_OF'); + expect(rolloutAlloc.splits[0].extraLogging).toEqual({ + allocationvalue_type: 'rollout', + owner: 'hippo', + }); }); }); }); From 5136bc52785e6fcf153f21653619978c81a088d1 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:38:06 -0500 Subject: [PATCH 5/5] Add comment explaining module-level stores and update JSDoc - Explain why configuration stores are kept at module level (EppoClient doesn't expose getters for store metadata or bandit configurations) - Change "Returns" to "Reconstructs" in getFlagsConfiguration JSDoc Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 6295514..d5bdd59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,11 @@ export { export { IClientConfig }; let clientInstance: EppoClient; + +// We keep references to the configuration stores at module level because EppoClient +// does not expose public getters for store metadata (format, createdAt, environment) +// or bandit configurations. These references are needed by getFlagsConfiguration() +// and getBanditsConfiguration() to reconstruct exportable configuration JSON. let flagConfigurationStore: MemoryOnlyConfigurationStore; let banditVariationConfigurationStore: MemoryOnlyConfigurationStore; let banditModelConfigurationStore: MemoryOnlyConfigurationStore; @@ -148,7 +153,7 @@ export function getInstance(): EppoClient { } /** - * Returns the current flags configuration as a JSON string. + * Reconstructs the current flags configuration as a JSON string. * This can be used to bootstrap another SDK instance using offlineInit(). * * @returns JSON string containing the flags configuration, or null if not initialized