From 00ab1ecd21cb6488ae69aea867ae62e9b54a5d7e Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 20:12:01 -0500 Subject: [PATCH 1/4] 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 d5bdd59..ffbedae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -253,6 +253,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 41441ba79f55b5d3c8df5f370674f6481a31662c Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:08:46 -0500 Subject: [PATCH 2/4] 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 128bac63d9004f0b411803d674ada6662357550f Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:24:33 -0500 Subject: [PATCH 3/4] 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 1eb8a4ed118b339ef3678f62c7bb313c42152231 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:46:04 -0500 Subject: [PATCH 4/4] 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: