@@ -40,6 +40,7 @@ import {
4040 IAssignmentLogger ,
4141 init ,
4242 NO_OP_EVENT_DISPATCHER ,
43+ offlineInit ,
4344} from '.' ;
4445
4546import SpyInstance = jest . SpyInstance ;
@@ -913,3 +914,286 @@ describe('EppoClient E2E test', () => {
913914 } ) ;
914915 } ) ;
915916} ) ;
917+
918+ describe ( 'offlineInit' , ( ) => {
919+ const flagKey = 'mock-experiment' ;
920+
921+ // Configuration for a single flag within the UFC.
922+ const mockUfcFlagConfig : Flag = {
923+ key : flagKey ,
924+ enabled : true ,
925+ variationType : VariationType . STRING ,
926+ variations : {
927+ control : {
928+ key : 'control' ,
929+ value : 'control' ,
930+ } ,
931+ 'variant-1' : {
932+ key : 'variant-1' ,
933+ value : 'variant-1' ,
934+ } ,
935+ 'variant-2' : {
936+ key : 'variant-2' ,
937+ value : 'variant-2' ,
938+ } ,
939+ } ,
940+ allocations : [
941+ {
942+ key : 'traffic-split' ,
943+ rules : [ ] ,
944+ splits : [
945+ {
946+ variationKey : 'control' ,
947+ shards : [
948+ {
949+ salt : 'some-salt' ,
950+ ranges : [ { start : 0 , end : 3400 } ] ,
951+ } ,
952+ ] ,
953+ } ,
954+ {
955+ variationKey : 'variant-1' ,
956+ shards : [
957+ {
958+ salt : 'some-salt' ,
959+ ranges : [ { start : 3400 , end : 6700 } ] ,
960+ } ,
961+ ] ,
962+ } ,
963+ {
964+ variationKey : 'variant-2' ,
965+ shards : [
966+ {
967+ salt : 'some-salt' ,
968+ ranges : [ { start : 6700 , end : 10000 } ] ,
969+ } ,
970+ ] ,
971+ } ,
972+ ] ,
973+ doLog : true ,
974+ } ,
975+ ] ,
976+ totalShards : 10000 ,
977+ } ;
978+
979+ // Helper to create a full configuration JSON string
980+ const createFlagsConfigJson = (
981+ flags : Record < string , Flag > ,
982+ options : { createdAt ?: string ; format ?: string } = { } ,
983+ ) : string => {
984+ return JSON . stringify ( {
985+ createdAt : options . createdAt ?? '2024-04-17T19:40:53.716Z' ,
986+ format : options . format ?? 'SERVER' ,
987+ environment : { name : 'Test' } ,
988+ flags,
989+ } ) ;
990+ } ;
991+
992+ describe ( 'basic initialization' , ( ) => {
993+ it ( 'initializes with flag configurations and returns correct assignments' , ( ) => {
994+ const client = offlineInit ( {
995+ flagsConfiguration : createFlagsConfigJson ( { [ flagKey ] : mockUfcFlagConfig } ) ,
996+ } ) ;
997+
998+ // subject-10 should get variant-1 based on the hash
999+ const assignment = client . getStringAssignment ( flagKey , 'subject-10' , { } , 'default-value' ) ;
1000+ expect ( assignment ) . toEqual ( 'variant-1' ) ;
1001+ } ) ;
1002+
1003+ it ( 'returns default value when flag is not found' , ( ) => {
1004+ const client = offlineInit ( {
1005+ flagsConfiguration : createFlagsConfigJson ( { [ flagKey ] : mockUfcFlagConfig } ) ,
1006+ } ) ;
1007+
1008+ const assignment = client . getStringAssignment (
1009+ 'non-existent-flag' ,
1010+ 'subject-10' ,
1011+ { } ,
1012+ 'default-value' ,
1013+ ) ;
1014+ expect ( assignment ) . toEqual ( 'default-value' ) ;
1015+ } ) ;
1016+
1017+ it ( 'initializes with empty configuration' , ( ) => {
1018+ const client = offlineInit ( {
1019+ flagsConfiguration : createFlagsConfigJson ( { } ) ,
1020+ } ) ;
1021+
1022+ const assignment = client . getStringAssignment ( flagKey , 'subject-10' , { } , 'default-value' ) ;
1023+ expect ( assignment ) . toEqual ( 'default-value' ) ;
1024+ } ) ;
1025+
1026+ it ( 'makes client available via getInstance()' , ( ) => {
1027+ offlineInit ( {
1028+ flagsConfiguration : createFlagsConfigJson ( { [ flagKey ] : mockUfcFlagConfig } ) ,
1029+ } ) ;
1030+
1031+ const client = getInstance ( ) ;
1032+ const assignment = client . getStringAssignment ( flagKey , 'subject-10' , { } , 'default-value' ) ;
1033+ expect ( assignment ) . toEqual ( 'variant-1' ) ;
1034+ } ) ;
1035+ } ) ;
1036+
1037+ describe ( 'assignment logging' , ( ) => {
1038+ it ( 'logs assignments when assignment logger is provided' , ( ) => {
1039+ const mockLogger = td . object < IAssignmentLogger > ( ) ;
1040+
1041+ const client = offlineInit ( {
1042+ flagsConfiguration : createFlagsConfigJson ( { [ flagKey ] : mockUfcFlagConfig } ) ,
1043+ assignmentLogger : mockLogger ,
1044+ } ) ;
1045+
1046+ client . getStringAssignment ( flagKey , 'subject-10' , { foo : 'bar' } , 'default-value' ) ;
1047+
1048+ expect ( td . explain ( mockLogger . logAssignment ) . callCount ) . toEqual ( 1 ) ;
1049+ const loggedAssignment = td . explain ( mockLogger . logAssignment ) . calls [ 0 ] . args [ 0 ] ;
1050+ expect ( loggedAssignment . subject ) . toEqual ( 'subject-10' ) ;
1051+ expect ( loggedAssignment . featureFlag ) . toEqual ( flagKey ) ;
1052+ expect ( loggedAssignment . allocation ) . toEqual ( 'traffic-split' ) ;
1053+ } ) ;
1054+
1055+ it ( 'does not throw when assignment logger throws' , ( ) => {
1056+ const mockLogger = td . object < IAssignmentLogger > ( ) ;
1057+ td . when ( mockLogger . logAssignment ( td . matchers . anything ( ) ) ) . thenThrow (
1058+ new Error ( 'logging error' ) ,
1059+ ) ;
1060+
1061+ const client = offlineInit ( {
1062+ flagsConfiguration : createFlagsConfigJson ( { [ flagKey ] : mockUfcFlagConfig } ) ,
1063+ assignmentLogger : mockLogger ,
1064+ } ) ;
1065+
1066+ // Should not throw, even though logger throws
1067+ const assignment = client . getStringAssignment ( flagKey , 'subject-10' , { } , 'default-value' ) ;
1068+ expect ( assignment ) . toEqual ( 'variant-1' ) ;
1069+ } ) ;
1070+ } ) ;
1071+
1072+ describe ( 'configuration metadata' , ( ) => {
1073+ it ( 'extracts createdAt from configuration as configPublishedAt' , ( ) => {
1074+ const createdAt = '2024-01-15T10:00:00.000Z' ;
1075+
1076+ const client = offlineInit ( {
1077+ flagsConfiguration : createFlagsConfigJson ( { [ flagKey ] : mockUfcFlagConfig } , { createdAt } ) ,
1078+ } ) ;
1079+
1080+ const result = client . getStringAssignmentDetails ( flagKey , 'subject-10' , { } , 'default-value' ) ;
1081+ expect ( result . evaluationDetails . configPublishedAt ) . toBe ( createdAt ) ;
1082+ } ) ;
1083+ } ) ;
1084+
1085+ describe ( 'error handling' , ( ) => {
1086+ it ( 'throws error by default when JSON parsing fails' , ( ) => {
1087+ expect ( ( ) => {
1088+ offlineInit ( {
1089+ flagsConfiguration : 'invalid json' ,
1090+ } ) ;
1091+ } ) . toThrow ( ) ;
1092+ } ) ;
1093+
1094+ it ( 'does not throw when throwOnFailedInitialization is false' , ( ) => {
1095+ expect ( ( ) => {
1096+ offlineInit ( {
1097+ flagsConfiguration : 'invalid json' ,
1098+ throwOnFailedInitialization : false ,
1099+ } ) ;
1100+ } ) . not . toThrow ( ) ;
1101+ } ) ;
1102+
1103+ it ( 'does not throw with valid empty flags configuration' , ( ) => {
1104+ expect ( ( ) => {
1105+ offlineInit ( {
1106+ flagsConfiguration : createFlagsConfigJson ( { } ) ,
1107+ } ) ;
1108+ } ) . not . toThrow ( ) ;
1109+ } ) ;
1110+ } ) ;
1111+
1112+ describe ( 'no network requests' , ( ) => {
1113+ it ( 'does not have configurationRequestParameters (no polling)' , ( ) => {
1114+ const client = offlineInit ( {
1115+ flagsConfiguration : createFlagsConfigJson ( { [ flagKey ] : mockUfcFlagConfig } ) ,
1116+ } ) ;
1117+
1118+ // Access the internal configurationRequestParameters - should be undefined for offline mode
1119+ const configurationRequestParameters = client [ 'configurationRequestParameters' ] ;
1120+ expect ( configurationRequestParameters ) . toBeUndefined ( ) ;
1121+ } ) ;
1122+ } ) ;
1123+
1124+ describe ( 'bandit support' , ( ) => {
1125+ it ( 'initializes with bandit references from configuration' , ( ) => {
1126+ const banditFlagKey = 'bandit-flag' ;
1127+ const banditKey = 'test-bandit' ;
1128+
1129+ const banditFlagConfig : Flag = {
1130+ key : banditFlagKey ,
1131+ enabled : true ,
1132+ variationType : VariationType . STRING ,
1133+ variations : {
1134+ bandit : {
1135+ key : 'bandit' ,
1136+ value : 'bandit' ,
1137+ } ,
1138+ control : {
1139+ key : 'control' ,
1140+ value : 'control' ,
1141+ } ,
1142+ } ,
1143+ allocations : [
1144+ {
1145+ key : 'bandit-allocation' ,
1146+ rules : [ ] ,
1147+ splits : [
1148+ {
1149+ variationKey : 'bandit' ,
1150+ shards : [
1151+ {
1152+ salt : 'salt' ,
1153+ ranges : [ { start : 0 , end : 10000 } ] ,
1154+ } ,
1155+ ] ,
1156+ } ,
1157+ ] ,
1158+ doLog : true ,
1159+ } ,
1160+ ] ,
1161+ totalShards : 10000 ,
1162+ } ;
1163+
1164+ // Create a configuration with bandit references
1165+ const flagsConfigJson = JSON . stringify ( {
1166+ createdAt : '2024-04-17T19:40:53.716Z' ,
1167+ format : 'SERVER' ,
1168+ environment : { name : 'Test' } ,
1169+ flags : { [ banditFlagKey ] : banditFlagConfig } ,
1170+ banditReferences : {
1171+ [ banditKey ] : {
1172+ modelVersion : 'v1' ,
1173+ flagVariations : [
1174+ {
1175+ key : 'bandit' ,
1176+ flagKey : banditFlagKey ,
1177+ variationKey : 'bandit' ,
1178+ variationValue : 'bandit' ,
1179+ } ,
1180+ ] ,
1181+ } ,
1182+ } ,
1183+ } ) ;
1184+
1185+ const client = offlineInit ( {
1186+ flagsConfiguration : flagsConfigJson ,
1187+ } ) ;
1188+
1189+ // Verify the client is initialized and can make assignments
1190+ const assignment = client . getStringAssignment (
1191+ banditFlagKey ,
1192+ 'subject-1' ,
1193+ { } ,
1194+ 'default-value' ,
1195+ ) ;
1196+ expect ( assignment ) . toEqual ( 'bandit' ) ;
1197+ } ) ;
1198+ } ) ;
1199+ } ) ;
0 commit comments