From 056feda6dddde865502b9913af3512448cf05b5c Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 1 Jul 2025 01:02:05 +0600 Subject: [PATCH 1/4] [FSSDK-11526] parse holdout from datafile into project config also add getHoldoutsForFlag() function --- lib/feature_toggle.ts | 32 ++++ lib/index.browser.ts | 1 + lib/project_config/project_config.spec.ts | 186 +++++++++++++++++++++- lib/project_config/project_config.ts | 60 +++++++ lib/shared_types.ts | 19 ++- 5 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 lib/feature_toggle.ts diff --git a/lib/feature_toggle.ts b/lib/feature_toggle.ts new file mode 100644 index 000000000..f19e0101b --- /dev/null +++ b/lib/feature_toggle.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This module contains feature flags that control the availability of features under development. + * Each flag represents a feature that is not yet ready for production release. These flags + * serve multiple purposes in our development workflow: + * + * When a new feature is in development, it can be safely merged into the main branch + * while remaining disabled in production. This allows continuous integration without + * affecting the stability of production releases. The feature code will be automatically + * removed in production builds through tree-shaking when the flag is disabled. + * + * During development and testing, these flags can be easily mocked to enable/disable + * specific features. Once a feature is complete and ready for release, its corresponding + * flag and all associated checks can be removed from the codebase. + */ + +export const holdout = () => false; diff --git a/lib/index.browser.ts b/lib/index.browser.ts index 4cbfc7c69..015e18582 100644 --- a/lib/index.browser.ts +++ b/lib/index.browser.ts @@ -19,6 +19,7 @@ import { getOptimizelyInstance } from './client_factory'; import { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher'; import { JAVASCRIPT_CLIENT_ENGINE } from './utils/enums'; import { BrowserRequestHandler } from './utils/http_request_handler/request_handler.browser'; +import * as featureToggle from './feature_toggle'; /** * Creates an instance of the Optimizely class diff --git a/lib/project_config/project_config.spec.ts b/lib/project_config/project_config.spec.ts index b7e7bea31..42c4916a5 100644 --- a/lib/project_config/project_config.spec.ts +++ b/lib/project_config/project_config.spec.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { describe, it, expect, beforeEach, afterEach, vi, assert, Mock } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi, assert, Mock, beforeAll, afterAll } from 'vitest'; import { sprintf } from '../utils/fns'; import { keyBy } from '../utils/fns'; -import projectConfig, { ProjectConfig, Region } from './project_config'; +import projectConfig, { ProjectConfig, getHoldoutsForFlag } from './project_config'; import { FEATURE_VARIABLE_TYPES, LOG_LEVEL } from '../utils/enums'; import testDatafile from '../tests/test_data'; import configValidator from '../utils/config_validator'; @@ -32,11 +32,20 @@ import { import { getMockLogger } from '../tests/mock/mock_logger'; import { VariableType } from '../shared_types'; import { OptimizelyError } from '../error/optimizly_error'; +import { mock } from 'node:test'; const buildLogMessageFromArgs = (args: any[]) => sprintf(args[1], ...args.splice(2)); const cloneDeep = (obj: any) => JSON.parse(JSON.stringify(obj)); const logger = getMockLogger(); +const mockHoldoutToggle = vi.hoisted(() => vi.fn()); + +vi.mock('../feature_toggle', () => { + return { + holdout: mockHoldoutToggle, + }; +}); + describe('createProjectConfig', () => { let configObj: ProjectConfig; @@ -298,6 +307,179 @@ describe('createProjectConfig - cmab experiments', () => { }); }); +const getHoldoutDatafile = () => { + const datafile = testDatafile.getTestDecideProjectConfig(); + + // Add holdouts to the datafile + datafile.holdouts = [ + { + id: 'holdout_id_1', + key: 'holdout_1', + status: 'Running', + includeFlags: [], + excludeFlags: [], + audienceIds: ['13389130056'], + audienceConditions: ['or', '13389130056'], + variations: [ + { + id: 'var_id_1', + key: 'holdout_variation_1', + variables: [] + } + ], + trafficAllocation: [ + { + entityId: 'var_id_1', + endOfRange: 5000 + } + ] + }, + { + id: 'holdout_id_2', + key: 'holdout_2', + status: 'Running', + includeFlags: [], + excludeFlags: ['feature_3'], + audienceIds: [], + audienceConditions: [], + variations: [ + { + id: 'var_id_2', + key: 'holdout_variation_2', + variables: [] + } + ], + trafficAllocation: [ + { + entityId: 'var_id_2', + endOfRange: 1000 + } + ] + }, + { + id: 'holdout_id_3', + key: 'holdout_3', + status: 'Draft', + includeFlags: ['feature_1'], + excludeFlags: [], + audienceIds: [], + audienceConditions: [], + variations: [ + { + id: 'var_id_2', + key: 'holdout_variation_2', + variables: [] + } + ], + trafficAllocation: [ + { + entityId: 'var_id_2', + endOfRange: 1000 + } + ] + } + ]; + + return datafile; +} + +describe('createProjectConfig - holdouts, feature toggle is on', () => { + beforeAll(() => { + mockHoldoutToggle.mockReturnValue(true); + }); + + afterAll(() => { + mockHoldoutToggle.mockReset(); + }); + + it('should populate holdouts fields correctly', function() { + const datafile = getHoldoutDatafile(); + + mockHoldoutToggle.mockReturnValue(true); + + const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile))); + + expect(configObj.holdouts).toHaveLength(3); + configObj.holdouts.forEach((holdout, i) => { + expect(holdout).toEqual(expect.objectContaining(datafile.holdouts[i])); + expect(holdout.variationKeyMap).toEqual( + keyBy(datafile.holdouts[i].variations, 'key') + ); + }); + + expect(configObj.holdoutIdMap).toEqual({ + holdout_id_1: configObj.holdouts[0], + holdout_id_2: configObj.holdouts[1], + holdout_id_3: configObj.holdouts[2], + }); + + expect(configObj.globalHoldouts).toHaveLength(2); + expect(configObj.globalHoldouts).toEqual([ + configObj.holdouts[0], // holdout_1 has empty includeFlags + configObj.holdouts[1] // holdout_2 has empty includeFlags + ]); + + expect(configObj.includedHoldouts).toEqual({ + feature_1: [configObj.holdouts[2]], // holdout_3 includes feature_1 + }); + + expect(configObj.excludedHoldouts).toEqual({ + feature_3: [configObj.holdouts[1]] // holdout_2 excludes feature_3 + }); + + expect(configObj.flagHoldoutsMap).toEqual({}); + }); + + it('should handle empty holdouts array', function() { + const datafile = testDatafile.getTestProjectConfig(); + + const configObj = projectConfig.createProjectConfig(datafile); + + expect(configObj.holdouts).toEqual([]); + expect(configObj.holdoutIdMap).toEqual({}); + expect(configObj.globalHoldouts).toEqual([]); + expect(configObj.includedHoldouts).toEqual({}); + expect(configObj.excludedHoldouts).toEqual({}); + expect(configObj.flagHoldoutsMap).toEqual({}); + }); +}); + +describe('getHoldoutsForFlag: feature toggle is on', () => { + beforeAll(() => { + mockHoldoutToggle.mockReturnValue(true); + }); + + afterAll(() => { + mockHoldoutToggle.mockReset(); + }); + + it('should return all applicable holdouts for a flag', () => { + const datafile = getHoldoutDatafile(); + const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile))); + + const feature1Holdouts = getHoldoutsForFlag(configObj, 'feature_1'); + expect(feature1Holdouts).toHaveLength(3); + expect(feature1Holdouts).toEqual([ + configObj.holdouts[0], + configObj.holdouts[1], + configObj.holdouts[2], + ]); + + const feature2Holdouts = getHoldoutsForFlag(configObj, 'feature_2'); + expect(feature2Holdouts).toHaveLength(2); + expect(feature2Holdouts).toEqual([ + configObj.holdouts[0], + configObj.holdouts[1], + ]); + + const feature3Holdouts = getHoldoutsForFlag(configObj, 'feature_3'); + expect(feature3Holdouts).toHaveLength(1); + expect(feature3Holdouts).toEqual([ + configObj.holdouts[0], + ]); + }); +}); + describe('getExperimentId', () => { let testData: Record; let configObj: ProjectConfig; diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 23e79989a..e10ac26c9 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -34,6 +34,7 @@ import { VariationVariable, Integration, FeatureVariableValue, + Holdout, } from '../shared_types'; import { OdpConfig, OdpIntegrationConfig } from '../odp/odp_config'; import { Transformer } from '../utils/type'; @@ -51,6 +52,7 @@ import { } from 'error_message'; import { SKIPPING_JSON_VALIDATION, VALID_DATAFILE } from 'log_message'; import { OptimizelyError } from '../error/optimizly_error'; +import * as featureToggle from '../feature_toggle'; interface TryCreatingProjectConfigConfig { // TODO[OASIS-6649]: Don't use object type @@ -110,6 +112,12 @@ export interface ProjectConfig { integrations: Integration[]; integrationKeyMap?: { [key: string]: Integration }; odpIntegrationConfig: OdpIntegrationConfig; + holdouts: Holdout[]; + holdoutIdMap?: { [id: string]: Holdout }; + globalHoldouts: Holdout[]; + includedHoldouts: { [key: string]: Holdout[]; } + excludedHoldouts: { [key: string]: Holdout[]; } + flagHoldoutsMap: { [key: string]: Holdout[]; } } const EXPERIMENT_RUNNING_STATUS = 'Running'; @@ -335,9 +343,61 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str projectConfig.flagVariationsMap[flagKey] = variations; }); + parseHoldoutsConfig(projectConfig); + return projectConfig; }; +const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => { + if (!featureToggle.holdout()) { + return; + } + + projectConfig.holdouts = projectConfig.holdouts || []; + projectConfig.holdoutIdMap = keyBy(projectConfig.holdouts, 'id'); + projectConfig.globalHoldouts = []; + projectConfig.includedHoldouts = {}; + projectConfig.excludedHoldouts = {}; + projectConfig.flagHoldoutsMap = {}; + + projectConfig.holdouts.forEach((holdout) => { + holdout.variationKeyMap = keyBy(holdout.variations, 'key'); + if (holdout.includeFlags.length === 0) { + projectConfig.globalHoldouts.push(holdout); + + holdout.excludeFlags.forEach((flagKey) => { + if (!projectConfig.excludedHoldouts[flagKey]) { + projectConfig.excludedHoldouts[flagKey] = []; + } + projectConfig.excludedHoldouts[flagKey].push(holdout); + }); + } else { + holdout.includeFlags.forEach((flagKey) => { + if (!projectConfig.includedHoldouts[flagKey]) { + projectConfig.includedHoldouts[flagKey] = []; + } + projectConfig.includedHoldouts[flagKey].push(holdout); + }); + } + }); +} + +export const getHoldoutsForFlag = (projectConfig: ProjectConfig, flagKey: string): Holdout[] => { + if (projectConfig.flagHoldoutsMap[flagKey]) { + return projectConfig.flagHoldoutsMap[flagKey]; + } + + const flagHoldouts: Holdout[] = [ + ...projectConfig.globalHoldouts.filter((holdout) => { + return !(projectConfig.excludedHoldouts[flagKey] || []).includes(holdout); + }), + ...(projectConfig.includedHoldouts[flagKey] || []), + ]; + + projectConfig.flagHoldoutsMap[flagKey] = flagHoldouts; + return flagHoldouts; +} + /** * Extract all audience segments used in this audience's conditions * @param {Audience} audience Object representing the audience being parsed diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 0b02adbc6..3d3492a2c 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -151,17 +151,20 @@ export interface Variation { variables?: VariationVariable[]; } -export interface Experiment { +export interface ExperimentCore { id: string; key: string; variations: Variation[]; variationKeyMap: { [key: string]: Variation }; - groupId?: string; - layerId: string; - status: string; audienceConditions: Array; audienceIds: string[]; trafficAllocation: TrafficAllocation[]; +} + +export interface Experiment extends ExperimentCore { + layerId: string; + groupId?: string; + status: string; forcedVariations?: { [key: string]: string }; isRollout?: boolean; cmab?: { @@ -170,6 +173,14 @@ export interface Experiment { }; } +export type HoldoutStatus = 'Draft' | 'Running' | 'Concluded' | 'Archived'; + +export interface Holdout extends ExperimentCore { + status: HoldoutStatus; + includeFlags: string[]; + excludeFlags: string[]; +} + export enum VariableType { BOOLEAN = 'boolean', DOUBLE = 'double', From 0f89f8fce6ae6402f04ed1299401c3366ea7396a Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 1 Jul 2025 18:03:35 +0600 Subject: [PATCH 2/4] update --- lib/index.browser.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/index.browser.ts b/lib/index.browser.ts index 015e18582..4cbfc7c69 100644 --- a/lib/index.browser.ts +++ b/lib/index.browser.ts @@ -19,7 +19,6 @@ import { getOptimizelyInstance } from './client_factory'; import { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher'; import { JAVASCRIPT_CLIENT_ENGINE } from './utils/enums'; import { BrowserRequestHandler } from './utils/http_request_handler/request_handler.browser'; -import * as featureToggle from './feature_toggle'; /** * Creates an instance of the Optimizely class From 5436b87966aefd8a2dfbbc9ec9b04dd65ff99985 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 1 Jul 2025 18:18:08 +0600 Subject: [PATCH 3/4] lint --- lib/feature_toggle.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/feature_toggle.ts b/lib/feature_toggle.ts index f19e0101b..22254e4f0 100644 --- a/lib/feature_toggle.ts +++ b/lib/feature_toggle.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + /** * This module contains feature flags that control the availability of features under development. * Each flag represents a feature that is not yet ready for production release. These flags From 466dc8885fe6a53c6e7f4dd653ff41f03d6bdee6 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 1 Jul 2025 23:15:20 +0600 Subject: [PATCH 4/4] handle undefined include and exclude flags --- lib/project_config/project_config.spec.ts | 12 ++++++++++++ lib/project_config/project_config.ts | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/project_config/project_config.spec.ts b/lib/project_config/project_config.spec.ts index 42c4916a5..662488914 100644 --- a/lib/project_config/project_config.spec.ts +++ b/lib/project_config/project_config.spec.ts @@ -442,6 +442,18 @@ describe('createProjectConfig - holdouts, feature toggle is on', () => { expect(configObj.excludedHoldouts).toEqual({}); expect(configObj.flagHoldoutsMap).toEqual({}); }); + + it('should handle undefined includeFlags and excludeFlags in holdout', function() { + const datafile = getHoldoutDatafile(); + datafile.holdouts[0].includeFlags = undefined; + datafile.holdouts[0].excludeFlags = undefined; + + const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile))); + + expect(configObj.holdouts).toHaveLength(3); + expect(configObj.holdouts[0].includeFlags).toEqual([]); + expect(configObj.holdouts[0].excludeFlags).toEqual([]); + }); }); describe('getHoldoutsForFlag: feature toggle is on', () => { diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index e10ac26c9..7ae95e3e9 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -361,6 +361,14 @@ const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => { projectConfig.flagHoldoutsMap = {}; projectConfig.holdouts.forEach((holdout) => { + if (!holdout.includeFlags) { + holdout.includeFlags = []; + } + + if (!holdout.excludeFlags) { + holdout.excludeFlags = []; + } + holdout.variationKeyMap = keyBy(holdout.variations, 'key'); if (holdout.includeFlags.length === 0) { projectConfig.globalHoldouts.push(holdout);