Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 102 additions & 3 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -501,6 +508,10 @@ describe('EppoClient E2E test', () => {
},
};

afterEach(() => {
td.reset();
});
Comment on lines +511 to +513
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The afterEach block calls td.reset() but the corresponding test 'retries initial configuration request before resolving' uses td.replace() on HttpClient.prototype. This cleanup should be in the existing test's cleanup (or a dedicated beforeEach/afterEach within that specific test), not added at the describe block level where it affects all tests in the 'Configuration Request' suite.

Copilot uses AI. Check for mistakes.

it('retries initial configuration request before resolving', async () => {
td.replace(HttpClient.prototype, 'getUniversalFlagConfiguration');
let callCount = 0;
Expand Down Expand Up @@ -635,8 +646,6 @@ describe('EppoClient E2E test', () => {
let isReadOnlyFsSpy: SpyInstance;

beforeEach(() => {
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed comment 'Reset the module before each test' and the removed jest.resetModules() call may have been important for test isolation. If module-level state (like the new flagConfigurationStore variables) persists between tests, this could cause test interdependencies. Verify that removing this reset doesn't cause tests to fail when run in different orders.

Suggested change
beforeEach(() => {
beforeEach(() => {
// Reset the module before each test to avoid shared module-level state between tests
jest.resetModules();

Copilot uses AI. Check for mistakes.
// Reset the module before each test
jest.resetModules();
// Create a spy on isReadOnlyFs that we can mock
isReadOnlyFsSpy = jest.spyOn(util, 'isReadOnlyFs');
});
Expand Down Expand Up @@ -735,4 +744,94 @@ describe('EppoClient E2E test', () => {
expect(configurationRequestParameters.pollAfterSuccessfulInitialization).toBe(false);
});
});

describe('getFlagsConfiguration', () => {
let client: EppoClient | null = null;

afterAll(() => {
if (client) {
client.stopPolling();
}
});

it('returns configuration JSON matching flags-v1.json structure', async () => {
client = 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 ?? '');

// Verify top-level metadata
expect(parsed.format).toBe('SERVER');
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',
});
});
});
});
115 changes: 112 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ 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<Flag>;
let banditVariationConfigurationStore: MemoryOnlyConfigurationStore<BanditVariation[]>;
let banditModelConfigurationStore: MemoryOnlyConfigurationStore<BanditParameters>;
Comment on lines +44 to +50
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying not to mess with the core SDK for now, but future us can expose these which would tidy this up a bit.


export const NO_OP_EVENT_DISPATCHER: EventDispatcher = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
attachContext: () => {},
Expand Down Expand Up @@ -86,9 +94,9 @@ export async function init(config: IClientConfig): Promise<EppoClient> {
throwOnFailedInitialization,
};

const flagConfigurationStore = new MemoryOnlyConfigurationStore<Flag>();
const banditVariationConfigurationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
const banditModelConfigurationStore = new MemoryOnlyConfigurationStore<BanditParameters>();
flagConfigurationStore = new MemoryOnlyConfigurationStore<Flag>();
banditVariationConfigurationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
banditModelConfigurationStore = new MemoryOnlyConfigurationStore<BanditParameters>();
const eventDispatcher = newEventDispatcher(apiKey, eventTracking);

clientInstance = new EppoClient({
Expand Down Expand Up @@ -144,6 +152,107 @@ export function getInstance(): EppoClient {
return clientInstance;
}

/**
* 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
* @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<string, Flag>;
banditReferences?: Record<
string,
{
modelVersion: string;
flagVariations: BanditVariation[];
}
>;
} = {
flags,
};

if (createdAt) {
configuration.createdAt = createdAt;
}
if (format) {
configuration.format = format;
}
if (environment) {
configuration.environment = environment;
}
Comment on lines +188 to +196
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If some of these values were null or undefined, it would be an invalid configuration. It might be better to return defaults instead. This would also allow you to reuse the IUniversalFlagConfigResponse type from common.

So you could do something like this:

  const config: IUniversalFlagConfigResponse = {
    createdAt: createdAt ?? new Date().toISOString(),
    format: format ?? FormatEnum.SERVER,
    environment: environment ?? { name: 'UNKNOWN' },
    flags,
    banditReferences: reconstructBanditReferences() ?? {},
  };

  return JSON.stringify(config);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also related to this comment


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<string, BanditVariation[]> = {};
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'] = {},
Expand Down