Skip to content

Commit 51438cf

Browse files
authored
[FSSDK-11526] parse holdout from datafile into project config (#1074)
* [FSSDK-11526] parse holdout from datafile into project config also add getHoldoutsForFlag() function
1 parent a7b62d9 commit 51438cf

File tree

4 files changed

+313
-6
lines changed

4 files changed

+313
-6
lines changed

lib/feature_toggle.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
18+
19+
/**
20+
* This module contains feature flags that control the availability of features under development.
21+
* Each flag represents a feature that is not yet ready for production release. These flags
22+
* serve multiple purposes in our development workflow:
23+
*
24+
* When a new feature is in development, it can be safely merged into the main branch
25+
* while remaining disabled in production. This allows continuous integration without
26+
* affecting the stability of production releases. The feature code will be automatically
27+
* removed in production builds through tree-shaking when the flag is disabled.
28+
*
29+
* During development and testing, these flags can be easily mocked to enable/disable
30+
* specific features. Once a feature is complete and ready for release, its corresponding
31+
* flag and all associated checks can be removed from the codebase.
32+
*/
33+
34+
export const holdout = () => false;

lib/project_config/project_config.spec.ts

Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
import { describe, it, expect, beforeEach, afterEach, vi, assert, Mock } from 'vitest';
16+
import { describe, it, expect, beforeEach, afterEach, vi, assert, Mock, beforeAll, afterAll } from 'vitest';
1717
import { sprintf } from '../utils/fns';
1818
import { keyBy } from '../utils/fns';
19-
import projectConfig, { ProjectConfig, Region } from './project_config';
19+
import projectConfig, { ProjectConfig, getHoldoutsForFlag } from './project_config';
2020
import { FEATURE_VARIABLE_TYPES, LOG_LEVEL } from '../utils/enums';
2121
import testDatafile from '../tests/test_data';
2222
import configValidator from '../utils/config_validator';
@@ -32,11 +32,20 @@ import {
3232
import { getMockLogger } from '../tests/mock/mock_logger';
3333
import { VariableType } from '../shared_types';
3434
import { OptimizelyError } from '../error/optimizly_error';
35+
import { mock } from 'node:test';
3536

3637
const buildLogMessageFromArgs = (args: any[]) => sprintf(args[1], ...args.splice(2));
3738
const cloneDeep = (obj: any) => JSON.parse(JSON.stringify(obj));
3839
const logger = getMockLogger();
3940

41+
const mockHoldoutToggle = vi.hoisted(() => vi.fn());
42+
43+
vi.mock('../feature_toggle', () => {
44+
return {
45+
holdout: mockHoldoutToggle,
46+
};
47+
});
48+
4049
describe('createProjectConfig', () => {
4150
let configObj: ProjectConfig;
4251

@@ -298,6 +307,191 @@ describe('createProjectConfig - cmab experiments', () => {
298307
});
299308
});
300309

310+
const getHoldoutDatafile = () => {
311+
const datafile = testDatafile.getTestDecideProjectConfig();
312+
313+
// Add holdouts to the datafile
314+
datafile.holdouts = [
315+
{
316+
id: 'holdout_id_1',
317+
key: 'holdout_1',
318+
status: 'Running',
319+
includeFlags: [],
320+
excludeFlags: [],
321+
audienceIds: ['13389130056'],
322+
audienceConditions: ['or', '13389130056'],
323+
variations: [
324+
{
325+
id: 'var_id_1',
326+
key: 'holdout_variation_1',
327+
variables: []
328+
}
329+
],
330+
trafficAllocation: [
331+
{
332+
entityId: 'var_id_1',
333+
endOfRange: 5000
334+
}
335+
]
336+
},
337+
{
338+
id: 'holdout_id_2',
339+
key: 'holdout_2',
340+
status: 'Running',
341+
includeFlags: [],
342+
excludeFlags: ['feature_3'],
343+
audienceIds: [],
344+
audienceConditions: [],
345+
variations: [
346+
{
347+
id: 'var_id_2',
348+
key: 'holdout_variation_2',
349+
variables: []
350+
}
351+
],
352+
trafficAllocation: [
353+
{
354+
entityId: 'var_id_2',
355+
endOfRange: 1000
356+
}
357+
]
358+
},
359+
{
360+
id: 'holdout_id_3',
361+
key: 'holdout_3',
362+
status: 'Draft',
363+
includeFlags: ['feature_1'],
364+
excludeFlags: [],
365+
audienceIds: [],
366+
audienceConditions: [],
367+
variations: [
368+
{
369+
id: 'var_id_2',
370+
key: 'holdout_variation_2',
371+
variables: []
372+
}
373+
],
374+
trafficAllocation: [
375+
{
376+
entityId: 'var_id_2',
377+
endOfRange: 1000
378+
}
379+
]
380+
}
381+
];
382+
383+
return datafile;
384+
}
385+
386+
describe('createProjectConfig - holdouts, feature toggle is on', () => {
387+
beforeAll(() => {
388+
mockHoldoutToggle.mockReturnValue(true);
389+
});
390+
391+
afterAll(() => {
392+
mockHoldoutToggle.mockReset();
393+
});
394+
395+
it('should populate holdouts fields correctly', function() {
396+
const datafile = getHoldoutDatafile();
397+
398+
mockHoldoutToggle.mockReturnValue(true);
399+
400+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
401+
402+
expect(configObj.holdouts).toHaveLength(3);
403+
configObj.holdouts.forEach((holdout, i) => {
404+
expect(holdout).toEqual(expect.objectContaining(datafile.holdouts[i]));
405+
expect(holdout.variationKeyMap).toEqual(
406+
keyBy(datafile.holdouts[i].variations, 'key')
407+
);
408+
});
409+
410+
expect(configObj.holdoutIdMap).toEqual({
411+
holdout_id_1: configObj.holdouts[0],
412+
holdout_id_2: configObj.holdouts[1],
413+
holdout_id_3: configObj.holdouts[2],
414+
});
415+
416+
expect(configObj.globalHoldouts).toHaveLength(2);
417+
expect(configObj.globalHoldouts).toEqual([
418+
configObj.holdouts[0], // holdout_1 has empty includeFlags
419+
configObj.holdouts[1] // holdout_2 has empty includeFlags
420+
]);
421+
422+
expect(configObj.includedHoldouts).toEqual({
423+
feature_1: [configObj.holdouts[2]], // holdout_3 includes feature_1
424+
});
425+
426+
expect(configObj.excludedHoldouts).toEqual({
427+
feature_3: [configObj.holdouts[1]] // holdout_2 excludes feature_3
428+
});
429+
430+
expect(configObj.flagHoldoutsMap).toEqual({});
431+
});
432+
433+
it('should handle empty holdouts array', function() {
434+
const datafile = testDatafile.getTestProjectConfig();
435+
436+
const configObj = projectConfig.createProjectConfig(datafile);
437+
438+
expect(configObj.holdouts).toEqual([]);
439+
expect(configObj.holdoutIdMap).toEqual({});
440+
expect(configObj.globalHoldouts).toEqual([]);
441+
expect(configObj.includedHoldouts).toEqual({});
442+
expect(configObj.excludedHoldouts).toEqual({});
443+
expect(configObj.flagHoldoutsMap).toEqual({});
444+
});
445+
446+
it('should handle undefined includeFlags and excludeFlags in holdout', function() {
447+
const datafile = getHoldoutDatafile();
448+
datafile.holdouts[0].includeFlags = undefined;
449+
datafile.holdouts[0].excludeFlags = undefined;
450+
451+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
452+
453+
expect(configObj.holdouts).toHaveLength(3);
454+
expect(configObj.holdouts[0].includeFlags).toEqual([]);
455+
expect(configObj.holdouts[0].excludeFlags).toEqual([]);
456+
});
457+
});
458+
459+
describe('getHoldoutsForFlag: feature toggle is on', () => {
460+
beforeAll(() => {
461+
mockHoldoutToggle.mockReturnValue(true);
462+
});
463+
464+
afterAll(() => {
465+
mockHoldoutToggle.mockReset();
466+
});
467+
468+
it('should return all applicable holdouts for a flag', () => {
469+
const datafile = getHoldoutDatafile();
470+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
471+
472+
const feature1Holdouts = getHoldoutsForFlag(configObj, 'feature_1');
473+
expect(feature1Holdouts).toHaveLength(3);
474+
expect(feature1Holdouts).toEqual([
475+
configObj.holdouts[0],
476+
configObj.holdouts[1],
477+
configObj.holdouts[2],
478+
]);
479+
480+
const feature2Holdouts = getHoldoutsForFlag(configObj, 'feature_2');
481+
expect(feature2Holdouts).toHaveLength(2);
482+
expect(feature2Holdouts).toEqual([
483+
configObj.holdouts[0],
484+
configObj.holdouts[1],
485+
]);
486+
487+
const feature3Holdouts = getHoldoutsForFlag(configObj, 'feature_3');
488+
expect(feature3Holdouts).toHaveLength(1);
489+
expect(feature3Holdouts).toEqual([
490+
configObj.holdouts[0],
491+
]);
492+
});
493+
});
494+
301495
describe('getExperimentId', () => {
302496
let testData: Record<string, any>;
303497
let configObj: ProjectConfig;

lib/project_config/project_config.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
VariationVariable,
3535
Integration,
3636
FeatureVariableValue,
37+
Holdout,
3738
} from '../shared_types';
3839
import { OdpConfig, OdpIntegrationConfig } from '../odp/odp_config';
3940
import { Transformer } from '../utils/type';
@@ -51,6 +52,7 @@ import {
5152
} from 'error_message';
5253
import { SKIPPING_JSON_VALIDATION, VALID_DATAFILE } from 'log_message';
5354
import { OptimizelyError } from '../error/optimizly_error';
55+
import * as featureToggle from '../feature_toggle';
5456

5557
interface TryCreatingProjectConfigConfig {
5658
// TODO[OASIS-6649]: Don't use object type
@@ -110,6 +112,12 @@ export interface ProjectConfig {
110112
integrations: Integration[];
111113
integrationKeyMap?: { [key: string]: Integration };
112114
odpIntegrationConfig: OdpIntegrationConfig;
115+
holdouts: Holdout[];
116+
holdoutIdMap?: { [id: string]: Holdout };
117+
globalHoldouts: Holdout[];
118+
includedHoldouts: { [key: string]: Holdout[]; }
119+
excludedHoldouts: { [key: string]: Holdout[]; }
120+
flagHoldoutsMap: { [key: string]: Holdout[]; }
113121
}
114122

115123
const EXPERIMENT_RUNNING_STATUS = 'Running';
@@ -335,9 +343,69 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str
335343
projectConfig.flagVariationsMap[flagKey] = variations;
336344
});
337345

346+
parseHoldoutsConfig(projectConfig);
347+
338348
return projectConfig;
339349
};
340350

351+
const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => {
352+
if (!featureToggle.holdout()) {
353+
return;
354+
}
355+
356+
projectConfig.holdouts = projectConfig.holdouts || [];
357+
projectConfig.holdoutIdMap = keyBy(projectConfig.holdouts, 'id');
358+
projectConfig.globalHoldouts = [];
359+
projectConfig.includedHoldouts = {};
360+
projectConfig.excludedHoldouts = {};
361+
projectConfig.flagHoldoutsMap = {};
362+
363+
projectConfig.holdouts.forEach((holdout) => {
364+
if (!holdout.includeFlags) {
365+
holdout.includeFlags = [];
366+
}
367+
368+
if (!holdout.excludeFlags) {
369+
holdout.excludeFlags = [];
370+
}
371+
372+
holdout.variationKeyMap = keyBy(holdout.variations, 'key');
373+
if (holdout.includeFlags.length === 0) {
374+
projectConfig.globalHoldouts.push(holdout);
375+
376+
holdout.excludeFlags.forEach((flagKey) => {
377+
if (!projectConfig.excludedHoldouts[flagKey]) {
378+
projectConfig.excludedHoldouts[flagKey] = [];
379+
}
380+
projectConfig.excludedHoldouts[flagKey].push(holdout);
381+
});
382+
} else {
383+
holdout.includeFlags.forEach((flagKey) => {
384+
if (!projectConfig.includedHoldouts[flagKey]) {
385+
projectConfig.includedHoldouts[flagKey] = [];
386+
}
387+
projectConfig.includedHoldouts[flagKey].push(holdout);
388+
});
389+
}
390+
});
391+
}
392+
393+
export const getHoldoutsForFlag = (projectConfig: ProjectConfig, flagKey: string): Holdout[] => {
394+
if (projectConfig.flagHoldoutsMap[flagKey]) {
395+
return projectConfig.flagHoldoutsMap[flagKey];
396+
}
397+
398+
const flagHoldouts: Holdout[] = [
399+
...projectConfig.globalHoldouts.filter((holdout) => {
400+
return !(projectConfig.excludedHoldouts[flagKey] || []).includes(holdout);
401+
}),
402+
...(projectConfig.includedHoldouts[flagKey] || []),
403+
];
404+
405+
projectConfig.flagHoldoutsMap[flagKey] = flagHoldouts;
406+
return flagHoldouts;
407+
}
408+
341409
/**
342410
* Extract all audience segments used in this audience's conditions
343411
* @param {Audience} audience Object representing the audience being parsed

lib/shared_types.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,20 @@ export interface Variation {
151151
variables?: VariationVariable[];
152152
}
153153

154-
export interface Experiment {
154+
export interface ExperimentCore {
155155
id: string;
156156
key: string;
157157
variations: Variation[];
158158
variationKeyMap: { [key: string]: Variation };
159-
groupId?: string;
160-
layerId: string;
161-
status: string;
162159
audienceConditions: Array<string | string[]>;
163160
audienceIds: string[];
164161
trafficAllocation: TrafficAllocation[];
162+
}
163+
164+
export interface Experiment extends ExperimentCore {
165+
layerId: string;
166+
groupId?: string;
167+
status: string;
165168
forcedVariations?: { [key: string]: string };
166169
isRollout?: boolean;
167170
cmab?: {
@@ -170,6 +173,14 @@ export interface Experiment {
170173
};
171174
}
172175

176+
export type HoldoutStatus = 'Draft' | 'Running' | 'Concluded' | 'Archived';
177+
178+
export interface Holdout extends ExperimentCore {
179+
status: HoldoutStatus;
180+
includeFlags: string[];
181+
excludeFlags: string[];
182+
}
183+
173184
export enum VariableType {
174185
BOOLEAN = 'boolean',
175186
DOUBLE = 'double',

0 commit comments

Comments
 (0)