11/**
2- * Copyright 2025, Optimizely
2+ * Copyright 2025-2026 , Optimizely
33 *
44 * Licensed under the Apache License, Version 2.0 (the "License");
55 * you may not use this file except in compliance with the License.
@@ -2212,6 +2212,11 @@ describe('DecisionService', () => {
22122212 return datafile ;
22132213 } ;
22142214
2215+ const globalHit = ( config : ProjectConfig ) => ( {
2216+ experiment : config . holdoutIdMap && config . holdoutIdMap [ 'holdout_running_id' ] ,
2217+ variation : config . variationIdMap [ 'holdout_variation_running_id' ] ,
2218+ } ) ;
2219+
22152220 it ( 'should return holdout variation immediately when excludeTargetedDeliveries is false' , async ( ) => {
22162221 const { decisionService } = getDecisionService ( ) ;
22172222 const config = createProjectConfig ( JSON . stringify ( getExcludeTDDatafile ( false ) ) ) ;
@@ -2281,10 +2286,8 @@ describe('DecisionService', () => {
22812286 experiment : config . experimentKeyMap [ 'delivery_1' ] ,
22822287 variation : config . variationIdMap [ '5004' ] ,
22832288 decisionSource : DECISION_SOURCES . ROLLOUT ,
2284- holdout : {
2285- experiment : config . holdoutIdMap && config . holdoutIdMap [ 'holdout_running_id' ] ,
2286- variation : config . variationIdMap [ 'holdout_variation_running_id' ] ,
2287- } ,
2289+ holdout : globalHit ( config ) ,
2290+ holdouts : [ globalHit ( config ) ] ,
22882291 } ) ;
22892292
22902293 const reasonStrings = variation . reasons . map ( ( r : any ) => typeof r === 'string' ? r : r [ 0 ] ) ;
@@ -2317,10 +2320,8 @@ describe('DecisionService', () => {
23172320 experiment : null ,
23182321 variation : null ,
23192322 decisionSource : DECISION_SOURCES . ROLLOUT ,
2320- holdout : {
2321- experiment : config . holdoutIdMap && config . holdoutIdMap [ 'holdout_running_id' ] ,
2322- variation : config . variationIdMap [ 'holdout_variation_running_id' ] ,
2323- } ,
2323+ holdout : globalHit ( config ) ,
2324+ holdouts : [ globalHit ( config ) ] ,
23242325 } ) ;
23252326 } ) ;
23262327
@@ -2353,10 +2354,8 @@ describe('DecisionService', () => {
23532354 experiment : null ,
23542355 variation : null ,
23552356 decisionSource : DECISION_SOURCES . ROLLOUT ,
2356- holdout : {
2357- experiment : config . holdoutIdMap && config . holdoutIdMap [ 'holdout_running_id' ] ,
2358- variation : config . variationIdMap [ 'holdout_variation_running_id' ] ,
2359- } ,
2357+ holdout : globalHit ( config ) ,
2358+ holdouts : [ globalHit ( config ) ] ,
23602359 } ) ;
23612360 } ) ;
23622361 } ) ;
@@ -3076,6 +3075,11 @@ describe('DecisionService', () => {
30763075 return datafile ;
30773076 } ;
30783077
3078+ const localHit = ( config : ProjectConfig ) => ( {
3079+ experiment : config . holdoutIdMap ! [ 'local_holdout_id' ] ,
3080+ variation : config . variationIdMap [ 'local_holdout_variation_id' ] ,
3081+ } ) ;
3082+
30793083 beforeEach ( ( ) => {
30803084 mockBucket . mockReset ( ) ;
30813085 } ) ;
@@ -3118,12 +3122,10 @@ describe('DecisionService', () => {
31183122 expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( 'global_holdout_id' ) ;
31193123 } ) ;
31203124
3121- 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 ( ) => {
3122- // exp_1 has id '2001'
3125+ it ( 'local holdout hit on an experiment rule excludes the user from that rule only; with no other rule serving the decision is null with the hit recorded' , async ( ) => {
31233126 const config = createProjectConfig ( JSON . stringify ( makeLocalHoldoutDatafile ( '2001' ) ) ) ;
31243127 const { decisionService } = getDecisionService ( ) ;
31253128
3126- // bucket returns holdout variation when evaluating the local holdout
31273129 mockBucket . mockImplementation ( ( params : BucketerParams ) => {
31283130 if ( params . experimentId === 'local_holdout_id' ) {
31293131 return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
@@ -3135,10 +3137,70 @@ describe('DecisionService', () => {
31353137 const feature = config . featureKeyMap [ 'flag_1' ] ;
31363138 const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
31373139
3138- // Should return holdout decision for the local holdout
3139- expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . HOLDOUT ) ;
3140- expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( 'local_holdout_id' ) ;
3141- expect ( value [ 0 ] . result . variation ?. id ) . toBe ( 'local_holdout_variation_id' ) ;
3140+ const hit = localHit ( config ) ;
3141+ expect ( value [ 0 ] . result ) . toEqual ( {
3142+ experiment : null ,
3143+ variation : null ,
3144+ decisionSource : DECISION_SOURCES . ROLLOUT ,
3145+ holdout : hit ,
3146+ holdouts : [ hit ] ,
3147+ } ) ;
3148+ expect ( mockBucket . mock . calls . some ( ( [ p ] ) => p . experimentId === '2001' ) ) . toBe ( false ) ;
3149+ } ) ;
3150+
3151+ it ( 'local holdout hit on an experiment rule continues to the next experiment rule, which serves' , async ( ) => {
3152+ const config = createProjectConfig ( JSON . stringify ( makeLocalHoldoutDatafile ( '2001' ) ) ) ;
3153+ const { decisionService } = getDecisionService ( ) ;
3154+
3155+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3156+ if ( params . experimentId === 'local_holdout_id' ) {
3157+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3158+ }
3159+ if ( params . experimentId === '2001' ) {
3160+ return { result : '5001' , reasons : [ ] } ;
3161+ }
3162+ if ( params . experimentId === '2002' ) {
3163+ return { result : '5002' , reasons : [ ] } ;
3164+ }
3165+ return { result : null , reasons : [ ] } ;
3166+ } ) ;
3167+
3168+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' , attributes : { age : 15 } } ) ;
3169+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3170+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3171+
3172+ const hit = localHit ( config ) ;
3173+ expect ( value [ 0 ] . result ) . toEqual ( {
3174+ experiment : config . experimentIdMap [ '2002' ] ,
3175+ variation : config . variationIdMap [ '5002' ] ,
3176+ decisionSource : DECISION_SOURCES . FEATURE_TEST ,
3177+ holdout : hit ,
3178+ holdouts : [ hit ] ,
3179+ } ) ;
3180+ } ) ;
3181+
3182+ it ( 'local holdout hit on an experiment rule continues to a delivery rule when no later experiment serves' , async ( ) => {
3183+ const config = createProjectConfig ( JSON . stringify ( makeLocalHoldoutDatafile ( '2001' ) ) ) ;
3184+ const { decisionService } = getDecisionService ( ) ;
3185+
3186+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3187+ if ( params . experimentId === 'local_holdout_id' ) {
3188+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3189+ }
3190+ if ( params . experimentId === '3001' ) {
3191+ return { result : '5004' , reasons : [ ] } ;
3192+ }
3193+ return { result : null , reasons : [ ] } ;
3194+ } ) ;
3195+
3196+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' , attributes : { age : 15 } } ) ;
3197+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3198+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3199+
3200+ expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . ROLLOUT ) ;
3201+ expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( '3001' ) ;
3202+ expect ( value [ 0 ] . result . variation ?. id ) . toBe ( '5004' ) ;
3203+ expect ( value [ 0 ] . result . holdouts ) . toEqual ( [ localHit ( config ) ] ) ;
31423204 } ) ;
31433205
31443206 it ( 'local holdout miss branch: user not bucketed into local holdout falls through to regular rule evaluation' , async ( ) => {
@@ -3202,31 +3264,191 @@ describe('DecisionService', () => {
32023264 expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( '2001' ) ;
32033265 } ) ;
32043266
3205- it ( 'local holdout applies to delivery rules (rollouts) as well as experiment rules' , async ( ) => {
3206- // delivery_1 has id '3001'
3267+ it ( 'local holdout hit on a delivery rule continues to the next delivery rule, not straight to everyone else' , async ( ) => {
32073268 const config = createProjectConfig ( JSON . stringify ( makeLocalHoldoutDatafile ( '3001' ) ) ) ;
32083269 const { decisionService } = getDecisionService ( ) ;
32093270
3210- // bucket returns null for all experiments and the local holdout variation for delivery rule
32113271 mockBucket . mockImplementation ( ( params : BucketerParams ) => {
32123272 if ( params . experimentId === 'local_holdout_id' ) {
32133273 return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
32143274 }
3275+ if ( params . experimentId === '3002' ) {
3276+ return { result : '5005' , reasons : [ ] } ;
3277+ }
3278+ if ( params . experimentId === 'default-rollout-id' ) {
3279+ return { result : '5007' , reasons : [ ] } ;
3280+ }
32153281 return { result : null , reasons : [ ] } ;
32163282 } ) ;
32173283
3218- // No audience attributes → experiments won't match, falls through to rollout
3219- const user = new OptimizelyUserContext ( {
3220- optimizely : { } as any ,
3221- userId : 'user1' ,
3222- attributes : { age : 15 } , // satisfies 4001 used by delivery_1
3284+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' , attributes : { age : 15 } } ) ;
3285+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3286+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3287+
3288+ const hit = localHit ( config ) ;
3289+ expect ( value [ 0 ] . result ) . toEqual ( {
3290+ experiment : config . experimentIdMap [ '3002' ] ,
3291+ variation : config . variationIdMap [ '5005' ] ,
3292+ decisionSource : DECISION_SOURCES . ROLLOUT ,
3293+ holdout : hit ,
3294+ holdouts : [ hit ] ,
32233295 } ) ;
3296+ } ) ;
3297+
3298+ it ( 'one local holdout targeting two rules records a hit for each rule' , async ( ) => {
3299+ const config = createProjectConfig ( JSON . stringify ( makeLocalHoldoutDatafile ( '2001' , [ '2001' , '3001' ] ) ) ) ;
3300+ const { decisionService } = getDecisionService ( ) ;
3301+
3302+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3303+ if ( params . experimentId === 'local_holdout_id' ) {
3304+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3305+ }
3306+ if ( params . experimentId === '3002' ) {
3307+ return { result : '5005' , reasons : [ ] } ;
3308+ }
3309+ return { result : null , reasons : [ ] } ;
3310+ } ) ;
3311+
3312+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' , attributes : { age : 15 } } ) ;
32243313 const feature = config . featureKeyMap [ 'flag_1' ] ;
32253314 const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
32263315
3227- // Should be a holdout decision from the local holdout targeting the delivery rule
3228- expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . HOLDOUT ) ;
3229- expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( 'local_holdout_id' ) ;
3316+ expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( '3002' ) ;
3317+ expect ( value [ 0 ] . result . holdouts ) . toEqual ( [ localHit ( config ) , localHit ( config ) ] ) ;
3318+ } ) ;
3319+
3320+ it ( 'holdouts list is ordered by evaluation: experiment-rule hits before delivery-rule hits, and holdout is the first entry' , async ( ) => {
3321+ const datafile = makeLocalHoldoutDatafile ( '2002' ) ;
3322+ ( datafile as any ) . localHoldouts . push ( {
3323+ ...( datafile as any ) . localHoldouts [ 0 ] ,
3324+ id : 'local_holdout_2_id' ,
3325+ key : 'local_holdout_2' ,
3326+ includedRules : [ '3001' ] ,
3327+ variations : [ { id : 'local_holdout_2_variation_id' , key : 'local_holdout_2_variation' , variables : [ ] } ] ,
3328+ trafficAllocation : [ { entityId : 'local_holdout_2_variation_id' , endOfRange : 10000 } ] ,
3329+ } ) ;
3330+ const config = createProjectConfig ( JSON . stringify ( datafile ) ) ;
3331+ const { decisionService } = getDecisionService ( ) ;
3332+
3333+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3334+ if ( params . experimentId === 'local_holdout_id' ) {
3335+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3336+ }
3337+ if ( params . experimentId === 'local_holdout_2_id' ) {
3338+ return { result : 'local_holdout_2_variation_id' , reasons : [ ] } ;
3339+ }
3340+ if ( params . experimentId === '3002' ) {
3341+ return { result : '5005' , reasons : [ ] } ;
3342+ }
3343+ return { result : null , reasons : [ ] } ;
3344+ } ) ;
3345+
3346+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' , attributes : { age : 15 } } ) ;
3347+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3348+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3349+
3350+ const expHit = localHit ( config ) ;
3351+ const deliveryHit = {
3352+ experiment : config . holdoutIdMap ! [ 'local_holdout_2_id' ] ,
3353+ variation : config . variationIdMap [ 'local_holdout_2_variation_id' ] ,
3354+ } ;
3355+ expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( '3002' ) ;
3356+ expect ( value [ 0 ] . result . holdouts ) . toEqual ( [ expHit , deliveryHit ] ) ;
3357+ expect ( value [ 0 ] . result . holdout ) . toEqual ( expHit ) ;
3358+ } ) ;
3359+
3360+ it ( 'global excludeTargetedDeliveries holdout hit is listed before local delivery-rule hits' , async ( ) => {
3361+ const datafile = makeLocalHoldoutDatafile ( '3001' ) ;
3362+ ( datafile as any ) . holdouts = [ {
3363+ id : 'global_holdout_id' ,
3364+ key : 'global_holdout' ,
3365+ status : 'Running' ,
3366+ includedFlags : [ ] ,
3367+ excludedFlags : [ ] ,
3368+ excludeTargetedDeliveries : true ,
3369+ audienceIds : [ ] ,
3370+ audienceConditions : [ ] ,
3371+ variations : [ { id : 'global_holdout_var_id' , key : 'global_holdout_var' , variables : [ ] } ] ,
3372+ trafficAllocation : [ { entityId : 'global_holdout_var_id' , endOfRange : 10000 } ] ,
3373+ } ] ;
3374+ const config = createProjectConfig ( JSON . stringify ( datafile ) ) ;
3375+ const { decisionService } = getDecisionService ( ) ;
3376+
3377+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3378+ if ( params . experimentId === 'global_holdout_id' ) {
3379+ return { result : 'global_holdout_var_id' , reasons : [ ] } ;
3380+ }
3381+ if ( params . experimentId === 'local_holdout_id' ) {
3382+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3383+ }
3384+ if ( params . experimentId === '3002' ) {
3385+ return { result : '5005' , reasons : [ ] } ;
3386+ }
3387+ return { result : null , reasons : [ ] } ;
3388+ } ) ;
3389+
3390+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' , attributes : { age : 15 } } ) ;
3391+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3392+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3393+
3394+ const globalHit = {
3395+ experiment : config . holdoutIdMap ! [ 'global_holdout_id' ] ,
3396+ variation : config . variationIdMap [ 'global_holdout_var_id' ] ,
3397+ } ;
3398+ expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( '3002' ) ;
3399+ expect ( value [ 0 ] . result . holdouts ) . toEqual ( [ globalHit , localHit ( config ) ] ) ;
3400+ expect ( value [ 0 ] . result . holdout ) . toEqual ( globalHit ) ;
3401+ } ) ;
3402+
3403+ it ( 'when several local holdouts match one rule only the first is recorded' , async ( ) => {
3404+ const datafile = makeLocalHoldoutDatafile ( '2001' ) ;
3405+ ( datafile as any ) . localHoldouts . push ( {
3406+ ...( datafile as any ) . localHoldouts [ 0 ] ,
3407+ id : 'local_holdout_2_id' ,
3408+ key : 'local_holdout_2' ,
3409+ variations : [ { id : 'local_holdout_2_variation_id' , key : 'local_holdout_2_variation' , variables : [ ] } ] ,
3410+ trafficAllocation : [ { entityId : 'local_holdout_2_variation_id' , endOfRange : 10000 } ] ,
3411+ } ) ;
3412+ const config = createProjectConfig ( JSON . stringify ( datafile ) ) ;
3413+ const { decisionService } = getDecisionService ( ) ;
3414+
3415+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3416+ if ( params . experimentId === 'local_holdout_id' ) {
3417+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3418+ }
3419+ if ( params . experimentId === 'local_holdout_2_id' ) {
3420+ return { result : 'local_holdout_2_variation_id' , reasons : [ ] } ;
3421+ }
3422+ return { result : null , reasons : [ ] } ;
3423+ } ) ;
3424+
3425+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' } ) ;
3426+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3427+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3428+
3429+ expect ( value [ 0 ] . result . holdouts ) . toEqual ( [ localHit ( config ) ] ) ;
3430+ } ) ;
3431+
3432+ it ( 'user with no local holdout hit gets no holdout fields on the decision' , async ( ) => {
3433+ const config = createProjectConfig ( JSON . stringify ( makeLocalHoldoutDatafile ( '2001' ) ) ) ;
3434+ const { decisionService } = getDecisionService ( ) ;
3435+
3436+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3437+ if ( params . experimentId === '2001' ) {
3438+ return { result : '5001' , reasons : [ ] } ;
3439+ }
3440+ return { result : null , reasons : [ ] } ;
3441+ } ) ;
3442+
3443+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' , attributes : { age : 15 } } ) ;
3444+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3445+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3446+
3447+ expect ( value [ 0 ] . result ) . toEqual ( {
3448+ experiment : config . experimentIdMap [ '2001' ] ,
3449+ variation : config . variationIdMap [ '5001' ] ,
3450+ decisionSource : DECISION_SOURCES . FEATURE_TEST ,
3451+ } ) ;
32303452 } ) ;
32313453
32323454 it ( 'forced decision beats a 100% traffic local holdout: forced decision takes precedence over local holdout' , async ( ) => {
@@ -3288,10 +3510,9 @@ describe('DecisionService', () => {
32883510 const feature = config . featureKeyMap [ 'flag_1' ] ;
32893511 const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
32903512
3291- // Local holdout applies normally even with excludeTargetedDeliveries set
3292- expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . HOLDOUT ) ;
3293- expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( 'local_holdout_id' ) ;
3294- expect ( value [ 0 ] . result . variation ?. id ) . toBe ( 'local_holdout_variation_id' ) ;
3513+ expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . ROLLOUT ) ;
3514+ expect ( value [ 0 ] . result . variation ) . toBeNull ( ) ;
3515+ expect ( value [ 0 ] . result . holdouts ) . toEqual ( [ localHit ( config ) ] ) ;
32953516 } ) ;
32963517 } ) ;
32973518} ) ;
0 commit comments