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: diff --git a/src/index.spec.ts b/src/index.spec.ts index 64a1075..a94c64d 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, @@ -834,4 +835,81 @@ 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 matching bandit-models-v1.json structure', 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(); + 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); + }); + }); }); 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'] = {},