From e6fdbfaeaabf9240e1871624394030cd5892f047 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 13 Mar 2026 22:18:04 +0600 Subject: [PATCH 1/3] [AI-FSSDK] [FSSDK-12337] Add Feature Rollout support --- lib/project_config/project_config.spec.ts | 160 ++++++++++++++++++++++ lib/project_config/project_config.ts | 46 +++++++ lib/shared_types.ts | 1 + 3 files changed, 207 insertions(+) diff --git a/lib/project_config/project_config.spec.ts b/lib/project_config/project_config.spec.ts index ad288c515..bee363762 100644 --- a/lib/project_config/project_config.spec.ts +++ b/lib/project_config/project_config.spec.ts @@ -1324,3 +1324,163 @@ describe('tryCreatingProjectConfig', () => { expect(logger.error).not.toHaveBeenCalled(); }); }); + +describe('Feature Rollout support', () => { + const makeDatafile = (overrides: Record = {}) => { + const base: Record = { + 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'); + }); +}); diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 2627abe09..6cd96c8bf 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -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 = {}; @@ -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'); diff --git a/lib/shared_types.ts b/lib/shared_types.ts index c46b38da6..4d39a317d 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -163,6 +163,7 @@ export interface Experiment extends ExperimentCore { status: string; forcedVariations?: { [key: string]: string }; isRollout?: boolean; + type?: string; cmab?: { trafficAllocation: number; attributeIds: string[]; From 674ec565d7f2defddee091171d6bbb36d44a00cf Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Mar 2026 22:39:59 +0600 Subject: [PATCH 2/3] [AI-FSSDK] [FSSDK-12337] Update experiment type values to short-form abbreviations and add constants --- lib/project_config/project_config.spec.ts | 6 +++--- lib/project_config/project_config.ts | 6 +++--- lib/utils/enums/index.ts | 8 ++++++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/project_config/project_config.spec.ts b/lib/project_config/project_config.spec.ts index bee363762..5cc1b6cba 100644 --- a/lib/project_config/project_config.spec.ts +++ b/lib/project_config/project_config.spec.ts @@ -1358,7 +1358,7 @@ describe('Feature Rollout support', () => { key: 'rollout_experiment', layerId: 'layer_rollout', status: 'Running', - type: 'feature_rollout', + type: 'fr', variations: [{ id: 'var_rollout_1', key: 'variation_rollout_1', variables: [] }], trafficAllocation: [{ entityId: 'var_rollout_1', endOfRange: 5000 }], audienceIds: [], @@ -1416,7 +1416,7 @@ describe('Feature Rollout support', () => { expect(abExperiment.type).toBeUndefined(); }); - it('should inject everyone else variation into feature_rollout experiments', () => { + it('should inject everyone else variation into fr (feature rollout) experiments', () => { const datafile = makeDatafile(); const config = projectConfig.createProjectConfig(datafile as any); const rolloutExperiment = config.experimentIdMap['exp_rollout']; @@ -1481,6 +1481,6 @@ describe('Feature Rollout support', () => { const datafile = makeDatafile(); const config = projectConfig.createProjectConfig(datafile as any); const rolloutExperiment = config.experimentIdMap['exp_rollout']; - expect(rolloutExperiment.type).toBe('feature_rollout'); + expect(rolloutExperiment.type).toBe('fr'); }); }); diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 6cd96c8bf..09a42c446 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -15,7 +15,7 @@ */ import { find, objectEntries, objectValues, keyBy, assignBy } from '../utils/fns'; -import { FEATURE_VARIABLE_TYPES } from '../utils/enums'; +import { EXPERIMENT_TYPES, FEATURE_VARIABLE_TYPES } from '../utils/enums'; import configValidator from '../utils/config_validator'; import { LoggerFacade } from '../logging/logger'; @@ -301,7 +301,7 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str }); }); - // Inject "everyone else" variation into feature_rollout experiments + // Inject "everyone else" variation into feature rollout (FR) experiments (projectConfig.featureFlags || []).forEach(featureFlag => { const everyoneElseVariation = getEveryoneElseVariation(projectConfig, featureFlag); if (!everyoneElseVariation) { @@ -309,7 +309,7 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str } (featureFlag.experimentIds || []).forEach(experimentId => { const experiment = projectConfig.experimentIdMap[experimentId]; - if (experiment && experiment.type === 'feature_rollout') { + if (experiment && experiment.type === EXPERIMENT_TYPES.FR) { experiment.variations.push(everyoneElseVariation); experiment.trafficAllocation.push({ entityId: everyoneElseVariation.id, diff --git a/lib/utils/enums/index.ts b/lib/utils/enums/index.ts index abc8e0b1d..1760eb603 100644 --- a/lib/utils/enums/index.ts +++ b/lib/utils/enums/index.ts @@ -61,6 +61,14 @@ export const DECISION_SOURCES = { export type DecisionSource = typeof DECISION_SOURCES[keyof typeof DECISION_SOURCES]; +export const EXPERIMENT_TYPES = { + AB: 'ab', + MAB: 'mab', + CMAB: 'cmab', + TD: 'td', + FR: 'fr', +} as const; + export const AUDIENCE_EVALUATION_TYPES = { RULE: 'rule', EXPERIMENT: 'experiment', From 9246b0a50c2af266ef1913ea7fa317cb67e76790 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Mar 2026 22:52:27 +0600 Subject: [PATCH 3/3] [AI-FSSDK] [FSSDK-12337] Add validation for experiment type field --- lib/project_config/project_config.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 09a42c446..42ca251f9 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -260,6 +260,18 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str projectConfig.experimentKeyMap = keyBy(projectConfig.experiments, 'key'); projectConfig.experimentIdMap = keyBy(projectConfig.experiments, 'id'); + const validExperimentTypes = new Set(Object.values(EXPERIMENT_TYPES)); + (projectConfig.experiments || []).forEach(experiment => { + if (experiment.type != null && !validExperimentTypes.has(experiment.type)) { + throw new OptimizelyError( + 'Experiment "%s" has invalid type "%s". Valid types: %s.', + experiment.key, + experiment.type, + Array.from(validExperimentTypes).join(', '), + ); + } + }); + projectConfig.variationIdMap = {}; projectConfig.variationVariableUsageMap = {}; (projectConfig.experiments || []).forEach(experiment => {