From 57bd1dda78b51e6a592fb7796080f304b3720184 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 20:12:01 -0500 Subject: [PATCH 1/6] Add getBanditsConfiguration() to export bandit models as JSON This function returns the current bandits configuration as a JSON string that can be used together with getFlagsConfiguration() to bootstrap another SDK instance. Returns null if no bandits are configured. Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 32 ++++++++++++++++++++++++++++++++ src/index.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/index.spec.ts b/src/index.spec.ts index 64a1075..f5b9374 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -33,6 +33,7 @@ import { import * as util from './util/index'; import { + getBanditsConfiguration, getFlagsConfiguration, getInstance, IAssignmentEvent, @@ -835,3 +836,34 @@ describe('EppoClient E2E test', () => { }); }); }); + +describe('getBanditsConfiguration', () => { + it('returns null when no bandits are configured', async () => { + await init({ + apiKey: 'dummy', + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + }); + + // The default mock doesn't include bandits, so this should return null + const banditsConfig = getBanditsConfiguration(); + expect(banditsConfig).toBeNull(); + }); + + it('returns bandits configuration JSON when bandits are present', async () => { + await init({ + apiKey: TEST_BANDIT_API_KEY, + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + banditLogger: { logBanditAction: jest.fn() }, + }); + + const banditsConfig = getBanditsConfiguration(); + // With the bandit API key, we should have bandits + if (banditsConfig) { + const parsed = JSON.parse(banditsConfig); + expect(parsed.bandits).toBeDefined(); + expect(Object.keys(parsed.bandits).length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/index.ts b/src/index.ts index 32344fd..e9c481a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -246,6 +246,35 @@ function reconstructBanditReferences(): Record { return banditReferences; } +/** + * Returns the current bandits configuration as a JSON string. + * This can be used together with getFlagsConfiguration() to bootstrap + * another SDK instance using offlineInit(). + * + * @returns JSON string containing the bandits configuration, or null if not initialized or no bandits + * @public + */ +export function getBanditsConfiguration(): string | null { + if (!banditModelConfigurationStore) { + return null; + } + + const bandits = banditModelConfigurationStore.entries(); + + // Return null if there are no bandits + if (Object.keys(bandits).length === 0) { + return null; + } + + const configuration: { + bandits: Record; + } = { + bandits, + }; + + return JSON.stringify(configuration); +} + function newEventDispatcher( sdkKey: string, config: IClientConfig['eventTracking'] = {}, From f2c3073e59a3bce0248b8409afd82d603b084391 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:08:46 -0500 Subject: [PATCH 2/6] Move getBanditsConfiguration tests inside main describe block Fixes test isolation issue where tests ran after API server was closed. Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 50 +++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index f5b9374..6403054 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -835,35 +835,35 @@ describe('EppoClient E2E test', () => { }); }); }); -}); -describe('getBanditsConfiguration', () => { - it('returns null when no bandits are configured', async () => { - await init({ - apiKey: 'dummy', - baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, - assignmentLogger: { logAssignment: jest.fn() }, + describe('getBanditsConfiguration', () => { + it('returns null when no bandits are configured', async () => { + await init({ + apiKey: 'dummy', + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + }); + + // The default mock doesn't include bandits, so this should return null + const banditsConfig = getBanditsConfiguration(); + expect(banditsConfig).toBeNull(); }); - // The default mock doesn't include bandits, so this should return null - const banditsConfig = getBanditsConfiguration(); - expect(banditsConfig).toBeNull(); - }); + it('returns bandits configuration JSON when bandits are present', async () => { + await init({ + apiKey: TEST_BANDIT_API_KEY, + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + banditLogger: { logBanditAction: jest.fn() }, + }); - it('returns bandits configuration JSON when bandits are present', async () => { - await init({ - apiKey: TEST_BANDIT_API_KEY, - baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, - assignmentLogger: { logAssignment: jest.fn() }, - banditLogger: { logBanditAction: jest.fn() }, + const banditsConfig = getBanditsConfiguration(); + // With the bandit API key, we should have bandits + if (banditsConfig) { + const parsed = JSON.parse(banditsConfig); + expect(parsed.bandits).toBeDefined(); + expect(Object.keys(parsed.bandits).length).toBeGreaterThan(0); + } }); - - const banditsConfig = getBanditsConfiguration(); - // With the bandit API key, we should have bandits - if (banditsConfig) { - const parsed = JSON.parse(banditsConfig); - expect(parsed.bandits).toBeDefined(); - expect(Object.keys(parsed.bandits).length).toBeGreaterThan(0); - } }); }); From afdbe658ba8d3abf854a8a091ccd7d11f2769a9e Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:24:33 -0500 Subject: [PATCH 3/6] Improve getBanditsConfiguration test with specific assertions Verify exact structure from bandit-models-v1.json including: - Exact number of bandits (3) - Specific bandit keys (banner_bandit, car_bandit, cold_start_bandit) - Detailed banner_bandit structure verification - Different settings for car_bandit and cold_start_bandit Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 60 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 6403054..a94c64d 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -849,7 +849,7 @@ describe('EppoClient E2E test', () => { expect(banditsConfig).toBeNull(); }); - it('returns bandits configuration JSON when bandits are present', async () => { + it('returns bandits configuration JSON matching bandit-models-v1.json structure', async () => { await init({ apiKey: TEST_BANDIT_API_KEY, baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, @@ -858,12 +858,58 @@ describe('EppoClient E2E test', () => { }); const banditsConfig = getBanditsConfiguration(); - // With the bandit API key, we should have bandits - if (banditsConfig) { - const parsed = JSON.parse(banditsConfig); - expect(parsed.bandits).toBeDefined(); - expect(Object.keys(parsed.bandits).length).toBeGreaterThan(0); - } + expect(banditsConfig).not.toBeNull(); + + const parsed = JSON.parse(banditsConfig ?? ''); + + // Verify exact number of bandits from bandit-models-v1.json + expect(Object.keys(parsed.bandits).length).toBe(3); + expect(Object.keys(parsed.bandits).sort()).toEqual([ + 'banner_bandit', + 'car_bandit', + 'cold_start_bandit', + ]); + + // Verify banner_bandit structure in detail + const bannerBandit = parsed.bandits['banner_bandit']; + expect(bannerBandit.banditKey).toBe('banner_bandit'); + expect(bannerBandit.modelName).toBe('falcon'); + expect(bannerBandit.modelVersion).toBe('123'); + expect(bannerBandit.updatedAt).toBe('2023-09-13T04:52:06.462Z'); + + // Verify modelData + expect(bannerBandit.modelData.gamma).toBe(1.0); + expect(bannerBandit.modelData.defaultActionScore).toBe(0.0); + expect(bannerBandit.modelData.actionProbabilityFloor).toBe(0.0); + + // Verify coefficients - should have nike and adidas + expect(Object.keys(bannerBandit.modelData.coefficients).sort()).toEqual(['adidas', 'nike']); + + // Verify nike coefficient structure + const nikeCoeff = bannerBandit.modelData.coefficients['nike']; + expect(nikeCoeff.actionKey).toBe('nike'); + expect(nikeCoeff.intercept).toBe(1.0); + expect(nikeCoeff.actionNumericCoefficients.length).toBe(1); + expect(nikeCoeff.actionNumericCoefficients[0]).toEqual({ + attributeKey: 'brand_affinity', + coefficient: 1.0, + missingValueCoefficient: -0.1, + }); + expect(nikeCoeff.actionCategoricalCoefficients.length).toBe(2); + expect(nikeCoeff.subjectNumericCoefficients.length).toBe(1); + expect(nikeCoeff.subjectCategoricalCoefficients.length).toBe(1); + + // Verify car_bandit has different settings + const carBandit = parsed.bandits['car_bandit']; + expect(carBandit.modelVersion).toBe('456'); + expect(carBandit.modelData.defaultActionScore).toBe(5.0); + expect(carBandit.modelData.actionProbabilityFloor).toBe(0.2); + expect(Object.keys(carBandit.modelData.coefficients)).toEqual(['toyota']); + + // Verify cold_start_bandit has empty coefficients + const coldStartBandit = parsed.bandits['cold_start_bandit']; + expect(coldStartBandit.modelVersion).toBe('cold start'); + expect(Object.keys(coldStartBandit.modelData.coefficients).length).toBe(0); }); }); }); From 1465b64c4e2ace2fae563d6de221945393292c8b Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:46:04 -0500 Subject: [PATCH 4/6] Fix CI workflow to run on PRs targeting branches with slashes Change branches pattern from "*" to "**" so the workflow triggers for PRs targeting branches like "aarsilv/feature-name" (stacked PRs). The single asterisk doesn't match "/" characters in GitHub Actions. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/lint-test-sdk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-test-sdk.yml b/.github/workflows/lint-test-sdk.yml index 3d320a8..c4096a6 100644 --- a/.github/workflows/lint-test-sdk.yml +++ b/.github/workflows/lint-test-sdk.yml @@ -6,7 +6,7 @@ env: on: pull_request: - branches: [ "*" ] + branches: [ "**" ] workflow_dispatch: workflow_call: inputs: From 89d5da3e8eb887d5d71096e9d88f5d53d8967507 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Sun, 25 Jan 2026 21:30:26 -0500 Subject: [PATCH 5/6] Use typed BanditsConfigurationResponse for getBanditsConfiguration() - Add BanditsConfigurationResponse interface matching IBanditParametersResponse structure from the common package (with updatedAt optional since not stored) - Update getBanditsConfiguration() to use the typed interface This ensures consistency with getFlagsConfiguration() and provides better type safety for the export/import workflow. Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index e9c481a..347636b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,16 @@ interface FlagsConfigurationResponse { banditReferences: Record; } +/** + * Represents the bandits configuration response format. + * This matches the IBanditParametersResponse interface from the common package's http-client, + * except updatedAt is optional since we don't store it separately. + */ +interface BanditsConfigurationResponse { + updatedAt?: string; + bandits: Record; +} + export const NO_OP_EVENT_DISPATCHER: EventDispatcher = { // eslint-disable-next-line @typescript-eslint/no-empty-function attachContext: () => {}, @@ -266,9 +276,9 @@ export function getBanditsConfiguration(): string | null { return null; } - const configuration: { - bandits: Record; - } = { + // Build configuration matching BanditsConfigurationResponse structure. + // Note: updatedAt is not available from the store, so it's omitted. + const configuration: BanditsConfigurationResponse = { bandits, }; From f3ee984ff2ce43d53a3faf472b0d06143a07a86a Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Sun, 25 Jan 2026 21:52:52 -0500 Subject: [PATCH 6/6] add comment --- src/index.spec.ts | 10 +++++++--- src/index.ts | 27 +++++++++------------------ 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index a94c64d..c501f29 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -837,16 +837,20 @@ describe('EppoClient E2E test', () => { }); describe('getBanditsConfiguration', () => { - it('returns null when no bandits are configured', async () => { + it('returns empty bandits configuration when no bandits are configured', async () => { await init({ apiKey: 'dummy', baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, assignmentLogger: { logAssignment: jest.fn() }, }); - // The default mock doesn't include bandits, so this should return null + // The default mock doesn't include bandits, so this should return an empty bandits map const banditsConfig = getBanditsConfiguration(); - expect(banditsConfig).toBeNull(); + expect(banditsConfig).not.toBeNull(); + expect(banditsConfig).toBeDefined(); + const parsed = JSON.parse(banditsConfig as string); + expect(parsed.bandits).toEqual({}); + expect(parsed.updatedAt).toBeDefined(); }); it('returns bandits configuration JSON matching bandit-models-v1.json structure', async () => { diff --git a/src/index.ts b/src/index.ts index 347636b..33c62c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,11 +80,13 @@ interface FlagsConfigurationResponse { /** * Represents the bandits configuration response format. - * This matches the IBanditParametersResponse interface from the common package's http-client, - * except updatedAt is optional since we don't store it separately. + * + * TODO: Remove this local definition once IBanditParametersResponse is exported from @eppo/js-client-sdk-common. + * This duplicates the IBanditParametersResponse interface from the common package's http-client module, + * which is not currently exported from the package's public API. */ interface BanditsConfigurationResponse { - updatedAt?: string; + updatedAt: string; bandits: Record; } @@ -261,25 +263,14 @@ function reconstructBanditReferences(): Record { * This can be used together with getFlagsConfiguration() to bootstrap * another SDK instance using offlineInit(). * - * @returns JSON string containing the bandits configuration, or null if not initialized or no bandits + * @returns JSON string containing the bandits configuration * @public */ -export function getBanditsConfiguration(): string | null { - if (!banditModelConfigurationStore) { - return null; - } - - const bandits = banditModelConfigurationStore.entries(); - - // Return null if there are no bandits - if (Object.keys(bandits).length === 0) { - return null; - } - +export function getBanditsConfiguration(): string { // Build configuration matching BanditsConfigurationResponse structure. - // Note: updatedAt is not available from the store, so it's omitted. const configuration: BanditsConfigurationResponse = { - bandits, + updatedAt: new Date().toISOString(), // TODO: ideally we can track this and use it when regenerating bandits configuration + bandits: banditModelConfigurationStore ? banditModelConfigurationStore.entries() : {}, }; return JSON.stringify(configuration);