@@ -416,12 +416,12 @@ describe('createProjectConfig - holdouts', () => {
416416 } ) ;
417417} ) ;
418418
419- // Level 1 tests for local holdouts (FSSDK-12369): isGlobal, getGlobalHoldouts, getHoldoutsForRule
420- describe ( 'createProjectConfig - local holdouts (FSSDK-12369)' , ( ) => {
421- const makeLocalHoldoutsDatafile = ( ) => {
419+ // Level 1 tests for local holdouts (FSSDK-12369, FSSDK-12760).
420+ // Section membership (`holdouts` vs `localHoldouts`) is the sole signal for scope.
421+ describe ( 'createProjectConfig - local holdouts (FSSDK-12369, FSSDK-12760)' , ( ) => {
422+ const makeHoldoutsDatafile = ( ) => {
422423 const datafile = testDatafile . getTestDecideProjectConfig ( ) ;
423- // Holdout with no includedRules → global
424- // Holdout with includedRules array → local
424+ // Entries in `holdouts` are global by section membership.
425425 ( datafile as any ) . holdouts = [
426426 {
427427 id : 'global_holdout_id' ,
@@ -433,8 +433,10 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
433433 audienceConditions : [ ] ,
434434 variations : [ { id : 'global_var_id' , key : 'global_var' , variables : [ ] } ] ,
435435 trafficAllocation : [ { entityId : 'global_var_id' , endOfRange : 5000 } ] ,
436- // no includedRules → isGlobal should be true
437436 } ,
437+ ] ;
438+ // Entries in `localHoldouts` are local; scoped via includedRules.
439+ ( datafile as any ) . localHoldouts = [
438440 {
439441 id : 'local_holdout_rule_a_id' ,
440442 key : 'local_holdout_rule_a' ,
@@ -447,70 +449,32 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
447449 variations : [ { id : 'local_var_id' , key : 'local_var' , variables : [ ] } ] ,
448450 trafficAllocation : [ { entityId : 'local_var_id' , endOfRange : 5000 } ] ,
449451 } ,
450- {
451- id : 'empty_local_holdout_id' ,
452- key : 'empty_local_holdout' ,
453- status : 'Running' ,
454- includedFlags : [ ] ,
455- excludedFlags : [ ] ,
456- audienceIds : [ ] ,
457- audienceConditions : [ ] ,
458- includedRules : [ ] , // empty array → local holdout (NOT global)
459- variations : [ { id : 'empty_var_id' , key : 'empty_var' , variables : [ ] } ] ,
460- trafficAllocation : [ { entityId : 'empty_var_id' , endOfRange : 5000 } ] ,
461- } ,
462- {
463- id : 'null_included_rules_id' ,
464- key : 'null_included_rules' ,
465- status : 'Running' ,
466- includedFlags : [ ] ,
467- excludedFlags : [ ] ,
468- audienceIds : [ ] ,
469- audienceConditions : [ ] ,
470- includedRules : null , // explicit null → global holdout
471- variations : [ { id : 'null_var_id' , key : 'null_var' , variables : [ ] } ] ,
472- trafficAllocation : [ { entityId : 'null_var_id' , endOfRange : 5000 } ] ,
473- } ,
474452 ] ;
475453 return datafile ;
476454 } ;
477455
478- it ( 'should set isGlobal=true for holdout with no includedRules field (backward compat with old datafiles)' , ( ) => {
479- const config = projectConfig . createProjectConfig ( cloneDeep ( makeLocalHoldoutsDatafile ( ) ) as any ) ;
456+ it ( 'should set isGlobal=true for entries in the holdouts section (backward compat with old datafiles)' , ( ) => {
457+ const config = projectConfig . createProjectConfig ( cloneDeep ( makeHoldoutsDatafile ( ) ) as any ) ;
480458 const holdout = config . holdoutIdMap ! [ 'global_holdout_id' ] ;
481459 expect ( holdout . isGlobal ) . toBe ( true ) ;
482460 } ) ;
483461
484- it ( 'should set isGlobal=false for holdout with includedRules array ' , ( ) => {
485- const config = projectConfig . createProjectConfig ( cloneDeep ( makeLocalHoldoutsDatafile ( ) ) as any ) ;
462+ it ( 'should set isGlobal=false for entries in the localHoldouts section ' , ( ) => {
463+ const config = projectConfig . createProjectConfig ( cloneDeep ( makeHoldoutsDatafile ( ) ) as any ) ;
486464 const holdout = config . holdoutIdMap ! [ 'local_holdout_rule_a_id' ] ;
487465 expect ( holdout . isGlobal ) . toBe ( false ) ;
488466 } ) ;
489467
490- it ( 'should set isGlobal=false for holdout with empty includedRules array (empty array is still local)' , ( ) => {
491- const config = projectConfig . createProjectConfig ( cloneDeep ( makeLocalHoldoutsDatafile ( ) ) as any ) ;
492- const holdout = config . holdoutIdMap ! [ 'empty_local_holdout_id' ] ;
493- expect ( holdout . isGlobal ) . toBe ( false ) ;
494- } ) ;
495-
496- it ( 'should set isGlobal=true for holdout with explicit null includedRules' , ( ) => {
497- const config = projectConfig . createProjectConfig ( cloneDeep ( makeLocalHoldoutsDatafile ( ) ) as any ) ;
498- const holdout = config . holdoutIdMap ! [ 'null_included_rules_id' ] ;
499- expect ( holdout . isGlobal ) . toBe ( true ) ;
500- } ) ;
501-
502- it ( 'getGlobalHoldouts should return only holdouts with isGlobal=true' , ( ) => {
503- const config = projectConfig . createProjectConfig ( cloneDeep ( makeLocalHoldoutsDatafile ( ) ) as any ) ;
468+ it ( 'getGlobalHoldouts should return only entries from the holdouts section' , ( ) => {
469+ const config = projectConfig . createProjectConfig ( cloneDeep ( makeHoldoutsDatafile ( ) ) as any ) ;
504470 const globals = getGlobalHoldouts ( config ) ;
505471 const globalIds = globals . map ( h => h . id ) ;
506472 expect ( globalIds ) . toContain ( 'global_holdout_id' ) ;
507- expect ( globalIds ) . toContain ( 'null_included_rules_id' ) ;
508473 expect ( globalIds ) . not . toContain ( 'local_holdout_rule_a_id' ) ;
509- expect ( globalIds ) . not . toContain ( 'empty_local_holdout_id' ) ;
510474 } ) ;
511475
512476 it ( 'getHoldoutsForRule should return local holdouts targeting the given rule ID' , ( ) => {
513- const config = projectConfig . createProjectConfig ( cloneDeep ( makeLocalHoldoutsDatafile ( ) ) as any ) ;
477+ const config = projectConfig . createProjectConfig ( cloneDeep ( makeHoldoutsDatafile ( ) ) as any ) ;
514478 const forRuleA = getHoldoutsForRule ( config , 'rule_a' ) ;
515479 expect ( forRuleA ) . toHaveLength ( 1 ) ;
516480 expect ( forRuleA [ 0 ] . id ) . toBe ( 'local_holdout_rule_a_id' ) ;
@@ -521,14 +485,15 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
521485 } ) ;
522486
523487 it ( 'getHoldoutsForRule should return empty array for an unknown rule ID' , ( ) => {
524- const config = projectConfig . createProjectConfig ( cloneDeep ( makeLocalHoldoutsDatafile ( ) ) as any ) ;
488+ const config = projectConfig . createProjectConfig ( cloneDeep ( makeHoldoutsDatafile ( ) ) as any ) ;
525489 const forUnknown = getHoldoutsForRule ( config , 'nonexistent_rule' ) ;
526490 expect ( forUnknown ) . toHaveLength ( 0 ) ;
527491 } ) ;
528492
529- it ( 'getGlobalHoldouts should return empty array when all holdouts are local' , ( ) => {
530- const datafile = cloneDeep ( makeLocalHoldoutsDatafile ( ) ) ;
531- ( datafile as any ) . holdouts = [
493+ it ( 'getGlobalHoldouts should return empty array when only the localHoldouts section is populated' , ( ) => {
494+ const datafile = cloneDeep ( makeHoldoutsDatafile ( ) ) ;
495+ ( datafile as any ) . holdouts = [ ] ;
496+ ( datafile as any ) . localHoldouts = [
532497 {
533498 id : 'only_local_id' ,
534499 key : 'only_local' ,
@@ -554,7 +519,7 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
554519 } ) ;
555520
556521 it ( 'a single local holdout targeting multiple rules should appear for each targeted rule' , ( ) => {
557- const config = projectConfig . createProjectConfig ( cloneDeep ( makeLocalHoldoutsDatafile ( ) ) as any ) ;
522+ const config = projectConfig . createProjectConfig ( cloneDeep ( makeHoldoutsDatafile ( ) ) as any ) ;
558523 const forRuleA = getHoldoutsForRule ( config , 'rule_a' ) ;
559524 const forRuleB = getHoldoutsForRule ( config , 'rule_b' ) ;
560525 // Both rule_a and rule_b point to the same holdout
@@ -563,6 +528,163 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => {
563528 } ) ;
564529} ) ;
565530
531+ // Level 1 tests for the FSSDK-12760 backward-compatible localHoldouts section design.
532+ // Older SDKs (Gen 1/Gen 2) ignore the unknown `localHoldouts` top-level key entirely.
533+ // Gen 3 SDKs (this one) treat section membership as the sole signal for scope.
534+ describe ( 'createProjectConfig - localHoldouts section (FSSDK-12760)' , ( ) => {
535+ const makeBaseDatafile = ( ) => testDatafile . getTestDecideProjectConfig ( ) ;
536+
537+ const makeLocal = ( id : string , key : string , includedRules : any ) => ( {
538+ id,
539+ key,
540+ status : 'Running' ,
541+ includedFlags : [ ] ,
542+ excludedFlags : [ ] ,
543+ audienceIds : [ ] ,
544+ audienceConditions : [ ] ,
545+ includedRules,
546+ variations : [ { id : `${ id } _var` , key : 'holdout' , variables : [ ] } ] ,
547+ trafficAllocation : [ { entityId : `${ id } _var` , endOfRange : 10000 } ] ,
548+ } ) ;
549+
550+ const makeGlobal = ( id : string , key : string , extra : Record < string , any > = { } ) => ( {
551+ id,
552+ key,
553+ status : 'Running' ,
554+ includedFlags : [ ] ,
555+ excludedFlags : [ ] ,
556+ audienceIds : [ ] ,
557+ audienceConditions : [ ] ,
558+ variations : [ { id : `${ id } _var` , key : 'holdout' , variables : [ ] } ] ,
559+ trafficAllocation : [ { entityId : `${ id } _var` , endOfRange : 10000 } ] ,
560+ ...extra ,
561+ } ) ;
562+
563+ it ( 'exposes localHoldouts as a top-level array on the project config' , ( ) => {
564+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
565+ datafile . holdouts = [ ] ;
566+ datafile . localHoldouts = [ makeLocal ( 'l1' , 'local_h' , [ 'rule_x' ] ) ] ;
567+ const config = projectConfig . createProjectConfig ( datafile ) ;
568+ expect ( Array . isArray ( config . localHoldouts ) ) . toBe ( true ) ;
569+ expect ( config . localHoldouts ) . toHaveLength ( 1 ) ;
570+ expect ( config . localHoldouts [ 0 ] . id ) . toBe ( 'l1' ) ;
571+ } ) ;
572+
573+ it ( 'defaults localHoldouts to an empty array when the section is absent (backward compat)' , ( ) => {
574+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
575+ datafile . holdouts = [ makeGlobal ( 'g1' , 'global_h' ) ] ;
576+ // No localHoldouts key at all
577+ const config = projectConfig . createProjectConfig ( datafile ) ;
578+ expect ( config . localHoldouts ) . toEqual ( [ ] ) ;
579+ expect ( getGlobalHoldouts ( config ) . map ( h => h . id ) ) . toContain ( 'g1' ) ;
580+ expect ( getHoldoutsForRule ( config , 'any_rule' ) ) . toEqual ( [ ] ) ;
581+ } ) ;
582+
583+ it ( 'ignores includedRules on entries in the global holdouts section' , ( ) => {
584+ // An includedRules field on a `holdouts` entry must NOT narrow its scope —
585+ // section membership is the sole signal for scope.
586+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
587+ datafile . holdouts = [
588+ makeGlobal ( 'stray' , 'stray_global' , { includedRules : [ 'rule_should_be_ignored' ] } ) ,
589+ ] ;
590+ datafile . localHoldouts = [ ] ;
591+ const config = projectConfig . createProjectConfig ( datafile ) ;
592+
593+ const stray = config . holdoutIdMap ! [ 'stray' ] ;
594+ // includedRules must be stripped at parse time
595+ expect ( stray . includedRules ) . toBeUndefined ( ) ;
596+ // Entity is global
597+ expect ( stray . isGlobal ) . toBe ( true ) ;
598+ // It appears in global list and NOT in the rule map
599+ expect ( getGlobalHoldouts ( config ) . map ( h => h . id ) ) . toContain ( 'stray' ) ;
600+ expect ( getHoldoutsForRule ( config , 'rule_should_be_ignored' ) ) . toEqual ( [ ] ) ;
601+ } ) ;
602+
603+ it ( 'does not mutate the caller-provided datafile when stripping includedRules' , ( ) => {
604+ // Regression guard: parsing a datafile must not bleed mutations into the
605+ // caller's reference (some hosts re-use the same datafile object).
606+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
607+ datafile . holdouts = [
608+ makeGlobal ( 'stray' , 'stray_global' , { includedRules : [ 'rule_x' ] } ) ,
609+ ] ;
610+ projectConfig . createProjectConfig ( datafile ) ;
611+ // Original datafile still has includedRules on the entry
612+ expect ( datafile . holdouts [ 0 ] . includedRules ) . toEqual ( [ 'rule_x' ] ) ;
613+ } ) ;
614+
615+ it ( 'partitions both sections correctly when both are present' , ( ) => {
616+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
617+ datafile . holdouts = [ makeGlobal ( 'g1' , 'g' ) , makeGlobal ( 'g2' , 'g2' ) ] ;
618+ datafile . localHoldouts = [
619+ makeLocal ( 'l1' , 'l1' , [ 'rule_a' ] ) ,
620+ makeLocal ( 'l2' , 'l2' , [ 'rule_b' ] ) ,
621+ ] ;
622+ const config = projectConfig . createProjectConfig ( datafile ) ;
623+
624+ expect ( getGlobalHoldouts ( config ) . map ( h => h . id ) . sort ( ) ) . toEqual ( [ 'g1' , 'g2' ] ) ;
625+ expect ( getHoldoutsForRule ( config , 'rule_a' ) . map ( h => h . id ) ) . toEqual ( [ 'l1' ] ) ;
626+ expect ( getHoldoutsForRule ( config , 'rule_b' ) . map ( h => h . id ) ) . toEqual ( [ 'l2' ] ) ;
627+ // ID map covers both sections
628+ expect ( config . holdoutIdMap ! [ 'g1' ] ) . toBeDefined ( ) ;
629+ expect ( config . holdoutIdMap ! [ 'l1' ] ) . toBeDefined ( ) ;
630+ } ) ;
631+
632+ it ( 'logs an error and excludes localHoldouts entries with no includedRules' , ( ) => {
633+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
634+ datafile . holdouts = [ ] ;
635+ // Invalid: no includedRules at all
636+ const invalid : any = makeLocal ( 'bad' , 'invalid_local' , undefined ) ;
637+ delete invalid . includedRules ;
638+ datafile . localHoldouts = [ invalid ] ;
639+
640+ const config = projectConfig . createProjectConfig ( datafile , null , logger ) ;
641+
642+ expect ( getGlobalHoldouts ( config ) ) . toEqual ( [ ] ) ;
643+ expect ( getHoldoutsForRule ( config , 'any_rule' ) ) . toEqual ( [ ] ) ;
644+ expect ( config . holdoutIdMap ! [ 'bad' ] ) . toBeUndefined ( ) ;
645+ expect ( logger . error ) . toHaveBeenCalledWith (
646+ expect . stringMatching ( / i n v a l i d _ l o c a l .* i n c l u d e d R u l e s / i)
647+ ) ;
648+ } ) ;
649+
650+ it ( 'logs an error and excludes localHoldouts entries with null includedRules' , ( ) => {
651+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
652+ datafile . holdouts = [ ] ;
653+ datafile . localHoldouts = [ makeLocal ( 'bad_null' , 'null_local' , null ) ] ;
654+
655+ const config = projectConfig . createProjectConfig ( datafile , null , logger ) ;
656+
657+ expect ( getHoldoutsForRule ( config , 'any_rule' ) ) . toEqual ( [ ] ) ;
658+ expect ( config . holdoutIdMap ! [ 'bad_null' ] ) . toBeUndefined ( ) ;
659+ expect ( logger . error ) . toHaveBeenCalledWith (
660+ expect . stringMatching ( / n u l l _ l o c a l .* i n c l u d e d R u l e s / i)
661+ ) ;
662+ } ) ;
663+
664+ it ( 'logs an error and excludes localHoldouts entries with empty includedRules' , ( ) => {
665+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
666+ datafile . holdouts = [ ] ;
667+ datafile . localHoldouts = [ makeLocal ( 'bad_empty' , 'empty_local' , [ ] ) ] ;
668+
669+ const config = projectConfig . createProjectConfig ( datafile , null , logger ) ;
670+
671+ expect ( getHoldoutsForRule ( config , 'any_rule' ) ) . toEqual ( [ ] ) ;
672+ expect ( config . holdoutIdMap ! [ 'bad_empty' ] ) . toBeUndefined ( ) ;
673+ expect ( logger . error ) . toHaveBeenCalledWith (
674+ expect . stringMatching ( / e m p t y _ l o c a l .* i n c l u d e d R u l e s / i)
675+ ) ;
676+ } ) ;
677+
678+ it ( 'keeps holdout variations in the variationIdMap from both sections' , ( ) => {
679+ const datafile = cloneDeep ( makeBaseDatafile ( ) ) as any ;
680+ datafile . holdouts = [ makeGlobal ( 'g1' , 'g' ) ] ;
681+ datafile . localHoldouts = [ makeLocal ( 'l1' , 'l' , [ 'rule_x' ] ) ] ;
682+ const config = projectConfig . createProjectConfig ( datafile ) ;
683+ expect ( config . variationIdMap [ 'g1_var' ] ) . toBeDefined ( ) ;
684+ expect ( config . variationIdMap [ 'l1_var' ] ) . toBeDefined ( ) ;
685+ } ) ;
686+ } ) ;
687+
566688describe ( 'getExperimentId' , ( ) => {
567689 let testData : Record < string , any > ;
568690 let configObj : ProjectConfig ;
0 commit comments