Skip to content

Commit a2b378c

Browse files
Mat001raju-opti
andauthored
[AI-FSSDK] [FSSDK-12369] Add local holdouts support to JavaScript SDK (#1151)
Co-authored-by: Raju Ahmed <raju.ahmed@optimizely.com>
1 parent 6c0a6e9 commit a2b378c

6 files changed

Lines changed: 565 additions & 58 deletions

File tree

lib/core/decision_service/index.spec.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2879,4 +2879,225 @@ describe('DecisionService', () => {
28792879
expect(variation).toBe(null);
28802880
});
28812881
});
2882+
2883+
describe('local holdouts', () => {
2884+
// Helper: build a datafile that has a local holdout targeting a specific experiment or delivery rule.
2885+
const makeLocalHoldoutDatafile = (targetRuleId: string, ruleIds: string[] = [targetRuleId]) => {
2886+
const datafile = getDecisionTestDatafile();
2887+
(datafile as any).holdouts = [
2888+
{
2889+
id: 'local_holdout_id',
2890+
key: 'local_holdout',
2891+
status: 'Running',
2892+
includedFlags: [],
2893+
excludedFlags: [],
2894+
includedRules: ruleIds,
2895+
audienceIds: [],
2896+
audienceConditions: [],
2897+
variations: [
2898+
{
2899+
id: 'local_holdout_variation_id',
2900+
key: 'local_holdout_variation',
2901+
variables: []
2902+
}
2903+
],
2904+
trafficAllocation: [
2905+
{ entityId: 'local_holdout_variation_id', endOfRange: 10000 }
2906+
]
2907+
}
2908+
];
2909+
return datafile;
2910+
};
2911+
2912+
beforeEach(() => {
2913+
mockBucket.mockReset();
2914+
});
2915+
2916+
it('global holdout branch: global holdout is evaluated before per-rule logic', async () => {
2917+
const datafile = getDecisionTestDatafile();
2918+
(datafile as any).holdouts = [
2919+
{
2920+
id: 'global_holdout_id',
2921+
key: 'global_holdout',
2922+
status: 'Running',
2923+
includedFlags: [],
2924+
excludedFlags: [],
2925+
// No includedRules → global holdout
2926+
audienceIds: [],
2927+
audienceConditions: [],
2928+
variations: [
2929+
{ id: 'global_holdout_var_id', key: 'global_holdout_var', variables: [] }
2930+
],
2931+
trafficAllocation: [{ entityId: 'global_holdout_var_id', endOfRange: 10000 }]
2932+
}
2933+
];
2934+
const config = createProjectConfig(datafile);
2935+
const { decisionService } = getDecisionService();
2936+
2937+
// bucket returns the global holdout variation for the holdout, nothing for experiments
2938+
mockBucket.mockImplementation((params: BucketerParams) => {
2939+
if (params.experimentId === 'global_holdout_id') {
2940+
return { result: 'global_holdout_var_id', reasons: [] };
2941+
}
2942+
return { result: null, reasons: [] };
2943+
});
2944+
2945+
const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'user1' });
2946+
const feature = config.featureKeyMap['flag_1'];
2947+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
2948+
2949+
// Decision should be from the global holdout, not from any experiment
2950+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT);
2951+
expect(value[0].result.experiment?.id).toBe('global_holdout_id');
2952+
});
2953+
2954+
it('local holdout hit branch: user bucketed into local holdout for experiment rule returns holdout variation; audience and traffic not evaluated for that rule', async () => {
2955+
// exp_1 has id '2001'
2956+
const config = createProjectConfig(makeLocalHoldoutDatafile('2001'));
2957+
const { decisionService } = getDecisionService();
2958+
2959+
// bucket returns holdout variation when evaluating the local holdout
2960+
mockBucket.mockImplementation((params: BucketerParams) => {
2961+
if (params.experimentId === 'local_holdout_id') {
2962+
return { result: 'local_holdout_variation_id', reasons: [] };
2963+
}
2964+
return { result: null, reasons: [] };
2965+
});
2966+
2967+
const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'user1' });
2968+
const feature = config.featureKeyMap['flag_1'];
2969+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
2970+
2971+
// Should return holdout decision for the local holdout
2972+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT);
2973+
expect(value[0].result.experiment?.id).toBe('local_holdout_id');
2974+
expect(value[0].result.variation?.id).toBe('local_holdout_variation_id');
2975+
});
2976+
2977+
it('local holdout miss branch: user not bucketed into local holdout falls through to regular rule evaluation', async () => {
2978+
// exp_1 has id '2001' and audience 4001 (age <= 22)
2979+
const config = createProjectConfig(makeLocalHoldoutDatafile('2001'));
2980+
const { decisionService } = getDecisionService();
2981+
2982+
// bucket returns null for the local holdout, then succeeds for the experiment
2983+
mockBucket.mockImplementation((params: BucketerParams) => {
2984+
if (params.experimentId === 'local_holdout_id') {
2985+
return { result: null, reasons: [] };
2986+
}
2987+
if (params.experimentId === '2001') {
2988+
return { result: '5001', reasons: [] }; // variation_1 in exp_1
2989+
}
2990+
return { result: null, reasons: [] };
2991+
});
2992+
2993+
const user = new OptimizelyUserContext({
2994+
optimizely: {} as any,
2995+
userId: 'user1',
2996+
attributes: { age: 15 }, // satisfies 4001 audience (age <= 22)
2997+
});
2998+
const feature = config.featureKeyMap['flag_1'];
2999+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
3000+
3001+
// Should fall through to experiment evaluation (not holdout)
3002+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.FEATURE_TEST);
3003+
expect(value[0].result.variation?.id).toBe('5001');
3004+
});
3005+
3006+
it('rule specificity: local holdout targeting experiment rule X does not affect experiment rule Y', async () => {
3007+
// exp_1 = '2001', exp_2 = '2002'. Local holdout targets only '2002' (exp_2).
3008+
// Audience for exp_1: 4001 (age <= 22). User satisfies exp_1 audience but not exp_2.
3009+
const config = createProjectConfig(makeLocalHoldoutDatafile('2002'));
3010+
const { decisionService } = getDecisionService();
3011+
3012+
// bucket returns holdout variation only for the local holdout when evaluating for '2002',
3013+
// and returns experiment variation for '2001'
3014+
mockBucket.mockImplementation((params: BucketerParams) => {
3015+
if (params.experimentId === 'local_holdout_id') {
3016+
return { result: 'local_holdout_variation_id', reasons: [] };
3017+
}
3018+
if (params.experimentId === '2001') {
3019+
return { result: '5001', reasons: [] };
3020+
}
3021+
return { result: null, reasons: [] };
3022+
});
3023+
3024+
// User satisfies exp_1 audience (age <= 22)
3025+
const user = new OptimizelyUserContext({
3026+
optimizely: {} as any,
3027+
userId: 'user1',
3028+
attributes: { age: 15 },
3029+
});
3030+
const feature = config.featureKeyMap['flag_1'];
3031+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
3032+
3033+
// exp_1 is evaluated first; local holdout targets '2002' not '2001', so exp_1 is evaluated normally
3034+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.FEATURE_TEST);
3035+
expect(value[0].result.experiment?.id).toBe('2001');
3036+
});
3037+
3038+
it('local holdout applies to delivery rules (rollouts) as well as experiment rules', async () => {
3039+
// delivery_1 has id '3001'
3040+
const config = createProjectConfig(makeLocalHoldoutDatafile('3001'));
3041+
const { decisionService } = getDecisionService();
3042+
3043+
// bucket returns null for all experiments and the local holdout variation for delivery rule
3044+
mockBucket.mockImplementation((params: BucketerParams) => {
3045+
if (params.experimentId === 'local_holdout_id') {
3046+
return { result: 'local_holdout_variation_id', reasons: [] };
3047+
}
3048+
return { result: null, reasons: [] };
3049+
});
3050+
3051+
// No audience attributes → experiments won't match, falls through to rollout
3052+
const user = new OptimizelyUserContext({
3053+
optimizely: {} as any,
3054+
userId: 'user1',
3055+
attributes: { age: 15 }, // satisfies 4001 used by delivery_1
3056+
});
3057+
const feature = config.featureKeyMap['flag_1'];
3058+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
3059+
3060+
// Should be a holdout decision from the local holdout targeting the delivery rule
3061+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT);
3062+
expect(value[0].result.experiment?.id).toBe('local_holdout_id');
3063+
});
3064+
3065+
it('forced decision beats a 100% traffic local holdout: forced decision takes precedence over local holdout', async () => {
3066+
// exp_1 has id '2001', key 'exp_1', variation key 'variation_1' (id '5001')
3067+
// Local holdout targets '2001' with 100% traffic allocation.
3068+
// User also has a forced decision set for exp_1.
3069+
// Expected: forced decision wins; decisionSource is FEATURE_TEST, not HOLDOUT.
3070+
const config = createProjectConfig(makeLocalHoldoutDatafile('2001'));
3071+
const { decisionService } = getDecisionService();
3072+
3073+
// bucket should NOT be called for local_holdout_id because forced decision short-circuits first
3074+
mockBucket.mockImplementation((params: BucketerParams) => {
3075+
if (params.experimentId === 'local_holdout_id') {
3076+
// returning holdout variation here to prove the test fails if ordering is wrong
3077+
return { result: 'local_holdout_variation_id', reasons: [] };
3078+
}
3079+
return { result: null, reasons: [] };
3080+
});
3081+
3082+
const user = new OptimizelyUserContext({
3083+
optimizely: {} as any,
3084+
userId: 'user1',
3085+
attributes: { age: 15 },
3086+
});
3087+
3088+
// Set forced decision for exp_1 → variation_1
3089+
user.setForcedDecision(
3090+
{ flagKey: 'flag_1', ruleKey: 'exp_1' },
3091+
{ variationKey: 'variation_1' }
3092+
);
3093+
3094+
const feature = config.featureKeyMap['flag_1'];
3095+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
3096+
3097+
// Forced decision must win — source must be FEATURE_TEST, not HOLDOUT
3098+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.FEATURE_TEST);
3099+
expect(value[0].result.variation?.key).toBe('variation_1');
3100+
});
3101+
});
28823102
});
3103+

0 commit comments

Comments
 (0)