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
160 changes: 160 additions & 0 deletions lib/project_config/project_config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,3 +1324,163 @@ describe('tryCreatingProjectConfig', () => {
expect(logger.error).not.toHaveBeenCalled();
});
});

describe('Feature Rollout support', () => {
const makeDatafile = (overrides: Record<string, any> = {}) => {
const base: Record<string, any> = {
version: '4',
revision: '1',
projectId: 'rollout_test',
accountId: '12345',
sdkKey: 'test-key',
environmentKey: 'production',
events: [],
audiences: [],
typedAudiences: [],
attributes: [],
groups: [],
integrations: [],
holdouts: [],
experiments: [
{
id: 'exp_ab',
key: 'ab_experiment',
layerId: 'layer_ab',
status: 'Running',
variations: [{ id: 'var_ab_1', key: 'variation_ab_1', variables: [] }],
trafficAllocation: [{ entityId: 'var_ab_1', endOfRange: 10000 }],
audienceIds: [],
audienceConditions: [],
forcedVariations: {},
},
{
id: 'exp_rollout',
key: 'rollout_experiment',
layerId: 'layer_rollout',
status: 'Running',
type: 'feature_rollout',
variations: [{ id: 'var_rollout_1', key: 'variation_rollout_1', variables: [] }],
trafficAllocation: [{ entityId: 'var_rollout_1', endOfRange: 5000 }],
audienceIds: [],
audienceConditions: [],
forcedVariations: {},
},
],
rollouts: [
{
id: 'rollout_1',
experiments: [
{
id: 'rollout_rule_1',
key: 'rollout_rule_1_key',
layerId: 'rollout_layer_1',
status: 'Running',
variations: [{ id: 'var_rr1', key: 'variation_rr1', variables: [] }],
trafficAllocation: [{ entityId: 'var_rr1', endOfRange: 10000 }],
audienceIds: [],
audienceConditions: [],
forcedVariations: {},
},
{
id: 'rollout_everyone_else',
key: 'rollout_everyone_else_key',
layerId: 'rollout_layer_ee',
status: 'Running',
variations: [{ id: 'var_ee', key: 'variation_everyone_else', variables: [] }],
trafficAllocation: [{ entityId: 'var_ee', endOfRange: 10000 }],
audienceIds: [],
audienceConditions: [],
forcedVariations: {},
},
],
},
],
featureFlags: [
{
id: 'feature_1',
key: 'feature_rollout_flag',
rolloutId: 'rollout_1',
experimentIds: ['exp_ab', 'exp_rollout'],
variables: [],
},
],
...overrides,
};
return base;
};

it('should preserve type=undefined for experiments without type field (backward compatibility)', () => {
const datafile = makeDatafile();
const config = projectConfig.createProjectConfig(datafile as any);
const abExperiment = config.experimentIdMap['exp_ab'];
expect(abExperiment.type).toBeUndefined();
});

it('should inject everyone else variation into feature_rollout experiments', () => {
const datafile = makeDatafile();
const config = projectConfig.createProjectConfig(datafile as any);
const rolloutExperiment = config.experimentIdMap['exp_rollout'];

// Should have 2 variations: original + injected everyone else
expect(rolloutExperiment.variations).toHaveLength(2);
expect(rolloutExperiment.variations[1].id).toBe('var_ee');
expect(rolloutExperiment.variations[1].key).toBe('variation_everyone_else');

// Should have injected traffic allocation entry
const lastAllocation = rolloutExperiment.trafficAllocation[rolloutExperiment.trafficAllocation.length - 1];
expect(lastAllocation.entityId).toBe('var_ee');
expect(lastAllocation.endOfRange).toBe(10000);
});

it('should update variation lookup maps with injected variation', () => {
const datafile = makeDatafile();
const config = projectConfig.createProjectConfig(datafile as any);
const rolloutExperiment = config.experimentIdMap['exp_rollout'];

// variationKeyMap on the experiment should contain the injected variation
expect(rolloutExperiment.variationKeyMap['variation_everyone_else']).toBeDefined();
expect(rolloutExperiment.variationKeyMap['variation_everyone_else'].id).toBe('var_ee');

// Global variationIdMap should contain the injected variation
expect(config.variationIdMap['var_ee']).toBeDefined();
expect(config.variationIdMap['var_ee'].key).toBe('variation_everyone_else');
});

it('should not modify non-rollout experiments (A/B, MAB, CMAB)', () => {
const datafile = makeDatafile();
const config = projectConfig.createProjectConfig(datafile as any);
const abExperiment = config.experimentIdMap['exp_ab'];

// A/B experiment should still have only 1 variation
expect(abExperiment.variations).toHaveLength(1);
expect(abExperiment.variations[0].id).toBe('var_ab_1');
expect(abExperiment.trafficAllocation).toHaveLength(1);
});

it('should silently skip injection when feature has no rolloutId', () => {
const datafile = makeDatafile({
featureFlags: [
{
id: 'feature_no_rollout',
key: 'feature_no_rollout',
rolloutId: '',
experimentIds: ['exp_rollout'],
variables: [],
},
],
});
const config = projectConfig.createProjectConfig(datafile as any);
const rolloutExperiment = config.experimentIdMap['exp_rollout'];

// Should still have only 1 variation (no injection)
expect(rolloutExperiment.variations).toHaveLength(1);
expect(rolloutExperiment.variations[0].id).toBe('var_rollout_1');
});

it('should correctly preserve experiment type field from datafile', () => {
const datafile = makeDatafile();
const config = projectConfig.createProjectConfig(datafile as any);
const rolloutExperiment = config.experimentIdMap['exp_rollout'];
expect(rolloutExperiment.type).toBe('feature_rollout');
});
});
46 changes: 46 additions & 0 deletions lib/project_config/project_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,30 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str
});
});

// Inject "everyone else" variation into feature_rollout experiments
(projectConfig.featureFlags || []).forEach(featureFlag => {
const everyoneElseVariation = getEveryoneElseVariation(projectConfig, featureFlag);
if (!everyoneElseVariation) {
return;
}
(featureFlag.experimentIds || []).forEach(experimentId => {
const experiment = projectConfig.experimentIdMap[experimentId];
if (experiment && experiment.type === 'feature_rollout') {
experiment.variations.push(everyoneElseVariation);
experiment.trafficAllocation.push({
entityId: everyoneElseVariation.id,
endOfRange: 10000,
});
// Update variation lookup maps
experiment.variationKeyMap[everyoneElseVariation.key] = everyoneElseVariation;
projectConfig.variationIdMap[everyoneElseVariation.id] = everyoneElseVariation;
if (everyoneElseVariation.variables) {
projectConfig.variationVariableUsageMap[everyoneElseVariation.id] = keyBy(everyoneElseVariation.variables, 'id');
}
}
});
});

// all rules (experiment rules and delivery rules) for each flag
projectConfig.flagRulesMap = {};

Expand Down Expand Up @@ -343,6 +367,28 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str
return projectConfig;
};

/**
* Get the "everyone else" variation from the last rule in the flag's rollout.
* Returns null if the rollout cannot be resolved or has no variations.
*/
const getEveryoneElseVariation = function(
projectConfig: ProjectConfig,
featureFlag: FeatureFlag,
): Variation | null {
if (!featureFlag.rolloutId) {
return null;
}
const rollout = projectConfig.rolloutIdMap[featureFlag.rolloutId];
if (!rollout || !rollout.experiments || rollout.experiments.length === 0) {
return null;
}
const everyoneElseRule = rollout.experiments[rollout.experiments.length - 1];
if (!everyoneElseRule.variations || everyoneElseRule.variations.length === 0) {
return null;
}
return everyoneElseRule.variations[0];
};

const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => {
projectConfig.holdouts = projectConfig.holdouts || [];
projectConfig.holdoutIdMap = keyBy(projectConfig.holdouts, 'id');
Expand Down
1 change: 1 addition & 0 deletions lib/shared_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export interface Experiment extends ExperimentCore {
status: string;
forcedVariations?: { [key: string]: string };
isRollout?: boolean;
type?: string;
cmab?: {
trafficAllocation: number;
attributeIds: string[];
Expand Down
Loading