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..c501f29 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,85 @@ describe('EppoClient E2E test', () => { }); }); }); + + describe('getBanditsConfiguration', () => { + 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 an empty bandits map + const banditsConfig = getBanditsConfiguration(); + 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 () => { + 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 32344fd..33c62c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,18 @@ interface FlagsConfigurationResponse { banditReferences: Record; } +/** + * Represents the bandits configuration response format. + * + * 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; + bandits: Record; +} + export const NO_OP_EVENT_DISPATCHER: EventDispatcher = { // eslint-disable-next-line @typescript-eslint/no-empty-function attachContext: () => {}, @@ -246,6 +258,24 @@ 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 + * @public + */ +export function getBanditsConfiguration(): string { + // Build configuration matching BanditsConfigurationResponse structure. + const configuration: BanditsConfigurationResponse = { + 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); +} + function newEventDispatcher( sdkKey: string, config: IClientConfig['eventTracking'] = {},