@webex/webex-core 3.12.0-task-refactor.1 → 3.12.0-webex-services-ready.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/config.js +7 -0
  2. package/dist/config.js.map +1 -1
  3. package/dist/credentials-config.js +12 -0
  4. package/dist/credentials-config.js.map +1 -1
  5. package/dist/interceptors/redirect.js +1 -1
  6. package/dist/interceptors/redirect.js.map +1 -1
  7. package/dist/lib/batcher.js +23 -7
  8. package/dist/lib/batcher.js.map +1 -1
  9. package/dist/lib/credentials/credentials.js +48 -4
  10. package/dist/lib/credentials/credentials.js.map +1 -1
  11. package/dist/lib/credentials/token.js +1 -1
  12. package/dist/lib/services/service-url.js +11 -1
  13. package/dist/lib/services/service-url.js.map +1 -1
  14. package/dist/lib/services/services.js +501 -96
  15. package/dist/lib/services/services.js.map +1 -1
  16. package/dist/lib/services-v2/services-v2.js +426 -42
  17. package/dist/lib/services-v2/services-v2.js.map +1 -1
  18. package/dist/lib/services-v2/types.js.map +1 -1
  19. package/dist/plugins/logger.js +1 -1
  20. package/dist/webex-core.js +2 -2
  21. package/dist/webex-core.js.map +1 -1
  22. package/package.json +13 -13
  23. package/src/config.js +7 -0
  24. package/src/credentials-config.js +13 -0
  25. package/src/interceptors/redirect.js +4 -1
  26. package/src/lib/batcher.js +25 -10
  27. package/src/lib/credentials/credentials.js +50 -3
  28. package/src/lib/services/service-url.js +9 -1
  29. package/src/lib/services/services.js +368 -14
  30. package/src/lib/services-v2/services-v2.ts +353 -10
  31. package/src/lib/services-v2/types.ts +5 -0
  32. package/test/integration/spec/services/service-catalog.js +16 -11
  33. package/test/integration/spec/services/services.js +38 -11
  34. package/test/integration/spec/services-v2/services-v2.js +29 -8
  35. package/test/unit/spec/credentials/credentials.js +133 -2
  36. package/test/unit/spec/lib/batcher.js +56 -0
  37. package/test/unit/spec/services/service-url.js +110 -0
  38. package/test/unit/spec/services/services.js +680 -80
  39. package/test/unit/spec/services-v2/services-v2.ts +484 -62
  40. package/test/unit/spec/webex-core.js +2 -0
  41. package/test/unit/spec/webex-internal-core.js +2 -0
@@ -15,6 +15,7 @@ import {
15
15
  ServiceHostmap,
16
16
  ServiceGroup,
17
17
  ServiceHost,
18
+ SelectionMeta,
18
19
  } from './types';
19
20
 
20
21
  const trailingSlashes = /(?:^\/)|(?:\/$)/;
@@ -28,6 +29,15 @@ const CLUSTER_SERVICE = process.env.WEBEX_CONVERSATION_CLUSTER_SERVICE || DEFAUL
28
29
  const DEFAULT_CLUSTER_IDENTIFIER =
29
30
  process.env.WEBEX_CONVERSATION_DEFAULT_CLUSTER || `${DEFAULT_CLUSTER}:${CLUSTER_SERVICE}`;
30
31
 
32
+ const CATALOG_CACHE_KEY_V2 = 'services.v2.u2cHostMap';
33
+ const CATALOG_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
34
+
35
+ // Maximum time we will wait for the initial catalog collection before letting
36
+ // `services.ready` (and therefore `webex.ready`) fire anyway. A hung request
37
+ // must never leave the app on a permanent spinner - past this point downstream
38
+ // consumers must fall through to their normal error/login paths.
39
+ const SERVICES_INIT_TIMEOUT_MS = 15_000;
40
+
31
41
  /* eslint-disable no-underscore-dangle */
32
42
  /**
33
43
  * @class
@@ -40,6 +50,20 @@ const Services = WebexPlugin.extend({
40
50
  initFailed: ['boolean', false, false],
41
51
  },
42
52
 
53
+ session: {
54
+ /**
55
+ * Becomes `true` once services initialization has completed.
56
+ * This blocks `webex.ready` until services are initialized.
57
+ * @instance
58
+ * @memberof Services
59
+ * @type {boolean}
60
+ */
61
+ ready: {
62
+ default: false,
63
+ type: 'boolean',
64
+ },
65
+ },
66
+
43
67
  _catalogs: new WeakMap(),
44
68
 
45
69
  _activeServices: {},
@@ -56,6 +80,46 @@ const Services = WebexPlugin.extend({
56
80
  return this._catalogs.get(this.webex);
57
81
  },
58
82
 
83
+ /**
84
+ * Safely access localStorage if available; returns the Storage or null.
85
+ * @returns {Storage | null}
86
+ */
87
+ _getLocalStorageSafe(): Storage | null {
88
+ if (typeof window !== 'undefined' && (window as any).localStorage) {
89
+ return (window as any).localStorage as Storage;
90
+ }
91
+
92
+ return null;
93
+ },
94
+
95
+ /**
96
+ * Determine the intended preauth selection based on the current context.
97
+ * @param {string} [currentOrgId]
98
+ * @returns {{selectionType: string, selectionValue: string}}
99
+ */
100
+ getIntendedPreauthSelection(currentOrgId?: string): {
101
+ selectionType: string;
102
+ selectionValue: string;
103
+ } {
104
+ if (this.webex.credentials?.canAuthorize) {
105
+ if (currentOrgId) {
106
+ return {selectionType: 'orgId', selectionValue: currentOrgId};
107
+ }
108
+ }
109
+
110
+ const emailConfig = this.webex.config && this.webex.config.email;
111
+
112
+ if (typeof emailConfig === 'string' && emailConfig.trim()) {
113
+ return {
114
+ selectionType: 'emailhash',
115
+ selectionValue: sha256(emailConfig.toLowerCase()).toString(),
116
+ };
117
+ }
118
+
119
+ // fall back to proximity mode when no orgId or email available
120
+ return {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'};
121
+ },
122
+
59
123
  /**
60
124
  * Get a service url from the current services list by name
61
125
  * from the associated instance catalog.
@@ -110,6 +174,7 @@ const Services = WebexPlugin.extend({
110
174
  * @returns {Array<ServiceHost>} - An array of `ServiceHost` objects.
111
175
  */
112
176
  getMobiusClusters(): Array<ServiceHost> {
177
+ this.logger.info('services: fetching mobius clusters');
113
178
  const clusters: Array<ServiceHost> = [];
114
179
  const services: Array<Service> = this._services || [];
115
180
 
@@ -153,6 +218,27 @@ const Services = WebexPlugin.extend({
153
218
  });
154
219
  });
155
220
  },
221
+
222
+ /**
223
+ * Checks if the current environment is an integration (INT) environment
224
+ * by examining the u2c discovery URL from webex config.
225
+ * INT environments use discovery URLs containing 'intb' (e.g., u2c-intb.ciscospark.com).
226
+ * @returns {boolean} True if INT environment, false otherwise
227
+ */
228
+ isIntegrationEnvironment(): boolean {
229
+ try {
230
+ const u2cUrl = this.webex?.config?.services?.discovery?.u2c || '';
231
+ const isInt = u2cUrl.includes('intb');
232
+
233
+ this.logger.info(`services: isIntegrationEnvironment: ${isInt}`);
234
+
235
+ return isInt;
236
+ } catch (error) {
237
+ this.logger.error('services: failed to determine integration environment', error);
238
+
239
+ return false;
240
+ }
241
+ },
156
242
  /**
157
243
  * saves all the services from the pre and post catalog service
158
244
  * @param {ActiveServices} activeServices
@@ -249,6 +335,18 @@ const Services = WebexPlugin.extend({
249
335
  serviceHostMap?.services,
250
336
  serviceHostMap?.timestamp
251
337
  );
338
+ // Build selection metadata for caching discrimination (preauth/signin)
339
+ let selectionMeta: SelectionMeta | undefined;
340
+ if (serviceGroup === 'preauth' || serviceGroup === 'signin') {
341
+ const key = formattedQuery && Object.keys(formattedQuery || {})[0];
342
+ if (key) {
343
+ selectionMeta = {
344
+ selectionType: key,
345
+ selectionValue: formattedQuery[key],
346
+ };
347
+ }
348
+ }
349
+ this._cacheCatalog(serviceGroup, serviceHostMap, selectionMeta);
252
350
  this.updateCredentialsConfig();
253
351
  catalog.status[serviceGroup].collecting = false;
254
352
  })
@@ -934,6 +1032,190 @@ const Services = WebexPlugin.extend({
934
1032
  return url.replace(data.defaultUrl, data.priorityUrl);
935
1033
  },
936
1034
 
1035
+ /**
1036
+ * @private
1037
+ * Cache the catalog in the bounded storage.
1038
+ * @param {ServiceGroup} serviceGroup - preauth, signin, postauth
1039
+ * @param {ServiceHostmap} hostMap - The hostmap to cache
1040
+ * @param {object} [meta] - Optional selection metadata for cache discrimination
1041
+ * @returns {Promise<void>}
1042
+ */
1043
+ async _cacheCatalog(
1044
+ serviceGroup: ServiceGroup,
1045
+ hostMap: ServiceHostmap,
1046
+ meta?: SelectionMeta
1047
+ ): Promise<void> {
1048
+ let current: {orgId?: string; env?: {fedramp?: boolean; u2cDiscoveryUrl?: string}} = {};
1049
+ let orgId: string | undefined;
1050
+ try {
1051
+ // Respect calling.cacheU2C toggle; if disabled, skip writing cache
1052
+ if (!this.webex.config?.calling?.cacheU2C) {
1053
+ this.logger.info(`services: skipping cache write for ${serviceGroup} as per the config`);
1054
+
1055
+ return;
1056
+ }
1057
+
1058
+ try {
1059
+ const ls = this._getLocalStorageSafe();
1060
+ const cachedJson = ls ? ls.getItem(CATALOG_CACHE_KEY_V2) : null;
1061
+ current = cachedJson ? JSON.parse(cachedJson) : {};
1062
+ } catch {
1063
+ current = {};
1064
+ }
1065
+
1066
+ try {
1067
+ const {credentials} = this.webex;
1068
+ orgId = credentials.getOrgId();
1069
+ } catch {
1070
+ orgId = current.orgId;
1071
+ }
1072
+
1073
+ // Capture environment fingerprint to invalidate cache across env changes
1074
+ let {env} = current;
1075
+ const fedramp = !!this.webex?.config?.fedramp;
1076
+ const u2cDiscoveryUrl = this.webex?.config?.services?.discovery?.u2c;
1077
+ env = {fedramp, u2cDiscoveryUrl};
1078
+
1079
+ const updated = {
1080
+ ...current,
1081
+ orgId: orgId || current.orgId,
1082
+ env: env || current.env,
1083
+ // When selection meta is provided, store as an object; otherwise keep legacy shape
1084
+ [serviceGroup]: meta ? {hostMap, meta} : hostMap,
1085
+ cachedAt: Date.now(),
1086
+ };
1087
+
1088
+ const ls = this._getLocalStorageSafe();
1089
+ if (ls) {
1090
+ ls.setItem(CATALOG_CACHE_KEY_V2, JSON.stringify(updated));
1091
+ }
1092
+ } catch (e) {
1093
+ this.logger.warn('services: error caching catalog', e);
1094
+ }
1095
+ },
1096
+
1097
+ /**
1098
+ * @private
1099
+ * Load the catalog from cache and hydrate the in-memory ServiceCatalog.
1100
+ * @returns {Promise<boolean>} true if cache was loaded, false otherwise
1101
+ */
1102
+ async _loadCatalogFromCache(): Promise<boolean> {
1103
+ let currentOrgId: string | undefined;
1104
+ try {
1105
+ // Respect calling.cacheU2C toggle; if disabled, skip using cache
1106
+ if (!this.webex.config?.calling?.cacheU2C) {
1107
+ this.logger.info('services: skipping cache warm-up as per the cache config');
1108
+
1109
+ return false;
1110
+ }
1111
+
1112
+ const ls = this._getLocalStorageSafe();
1113
+ if (!ls) {
1114
+ this.logger.info('services: skipping cache warm-up as no localStorage is available');
1115
+
1116
+ return false;
1117
+ }
1118
+ const cachedJson = ls.getItem(CATALOG_CACHE_KEY_V2);
1119
+ const cached = cachedJson ? JSON.parse(cachedJson) : undefined;
1120
+ if (!cached) {
1121
+ return false;
1122
+ }
1123
+
1124
+ // TTL enforcement
1125
+ const cachedAt = Number(cached.cachedAt) || 0;
1126
+ if (!cachedAt || Date.now() - cachedAt > CATALOG_TTL_MS) {
1127
+ this.clearCatalogCache();
1128
+
1129
+ return false;
1130
+ }
1131
+
1132
+ // If authorized, ensure cached org matches
1133
+ try {
1134
+ if (this.webex.credentials?.canAuthorize) {
1135
+ const {credentials} = this.webex;
1136
+ currentOrgId = credentials.getOrgId();
1137
+ if (cached.orgId && cached.orgId !== currentOrgId) {
1138
+ return false;
1139
+ }
1140
+ }
1141
+ } catch (e) {
1142
+ this.logger.warn('services: error checking orgId', e);
1143
+ }
1144
+
1145
+ // Ensure cached environment matches current environment
1146
+
1147
+ const fedramp = !!this.webex.config?.fedramp;
1148
+ const u2cDiscoveryUrl = this.webex.config?.services?.discovery?.u2c;
1149
+ const currentEnv = {fedramp, u2cDiscoveryUrl};
1150
+ if (cached.env) {
1151
+ const sameEnv =
1152
+ cached.env.fedramp === currentEnv.fedramp &&
1153
+ cached.env.u2cDiscoveryUrl === currentEnv.u2cDiscoveryUrl;
1154
+ if (!sameEnv) {
1155
+ return false;
1156
+ }
1157
+ }
1158
+
1159
+ const catalog = this._getCatalog();
1160
+ const groups: Array<ServiceGroup> = ['preauth', 'signin', 'postauth'];
1161
+
1162
+ groups.forEach((serviceGroup) => {
1163
+ const cachedGroup = cached[serviceGroup];
1164
+ if (!cachedGroup) {
1165
+ return;
1166
+ }
1167
+
1168
+ // Support legacy (hostMap) and new ({hostMap, meta}) shapes
1169
+ const hostMap: ServiceHostmap =
1170
+ cachedGroup && cachedGroup.hostMap ? cachedGroup.hostMap : cachedGroup;
1171
+ const meta: SelectionMeta | undefined = cachedGroup?.meta;
1172
+
1173
+ if (serviceGroup === 'preauth' && meta) {
1174
+ // For proximity-based selection, always fetch fresh to respect IP/region changes
1175
+ if (meta.selectionType === 'mode') {
1176
+ return;
1177
+ }
1178
+
1179
+ const intended = this.getIntendedPreauthSelection(currentOrgId);
1180
+ const matches =
1181
+ intended &&
1182
+ intended.selectionType === meta.selectionType &&
1183
+ intended.selectionValue === meta.selectionValue;
1184
+ if (!matches) {
1185
+ return;
1186
+ }
1187
+ }
1188
+
1189
+ if (hostMap) {
1190
+ catalog.updateServiceGroups(serviceGroup, hostMap?.services, hostMap?.timestamp);
1191
+ }
1192
+ });
1193
+
1194
+ this.updateCredentialsConfig();
1195
+
1196
+ return true;
1197
+ } catch (e) {
1198
+ return false;
1199
+ }
1200
+ },
1201
+
1202
+ /**
1203
+ * Clear the catalog cache from the bounded storage (v2).
1204
+ * @returns {Promise<void>}
1205
+ */
1206
+ clearCatalogCache(): Promise<void> {
1207
+ try {
1208
+ const ls = this._getLocalStorageSafe();
1209
+ if (ls) {
1210
+ ls.removeItem(CATALOG_CACHE_KEY_V2);
1211
+ }
1212
+ } catch (e) {
1213
+ this.logger.warn('services: error clearing catalog cache', e);
1214
+ }
1215
+
1216
+ return Promise.resolve();
1217
+ },
1218
+
937
1219
  /**
938
1220
  * @private
939
1221
  * Simplified method wrapper for sending a request to get
@@ -1075,6 +1357,30 @@ const Services = WebexPlugin.extend({
1075
1357
  );
1076
1358
  },
1077
1359
 
1360
+ /**
1361
+ * Await any in-flight credentials refresh, then flip `services.ready` so
1362
+ * `webex.ready` can fire. Closes the parallel-refresh window: if a credential
1363
+ * refresh is in flight when initial catalog collection settles, we must not
1364
+ * signal ready until the refresh has resolved - otherwise downstream
1365
+ * consumers may observe `canAuthorize`/token state that is about to change
1366
+ * under them.
1367
+ *
1368
+ * @private
1369
+ * @returns {Promise<void>}
1370
+ */
1371
+ async _finalizeReady(): Promise<void> {
1372
+ const {credentials} = this.webex;
1373
+
1374
+ if (credentials && credentials.isRefreshing) {
1375
+ await new Promise<void>((resolve) => {
1376
+ credentials.once('change:isRefreshing', resolve);
1377
+ });
1378
+ }
1379
+
1380
+ this.ready = true;
1381
+ this.trigger('services:initialized');
1382
+ },
1383
+
1078
1384
  /**
1079
1385
  * Initializer
1080
1386
  *
@@ -1091,13 +1397,32 @@ const Services = WebexPlugin.extend({
1091
1397
  this.initConfig();
1092
1398
  });
1093
1399
 
1094
- // wait for webex instance to be ready before attempting
1095
- // to update the service catalogs
1096
- this.listenToOnce(this.webex, 'ready', () => {
1400
+ // Wait for storage to be loaded before attempting to update the service catalogs.
1401
+ // We listen for 'loaded' instead of 'ready' because services.ready is a dependency
1402
+ // of webex.ready - listening to 'ready' would cause a deadlock.
1403
+ this.listenToOnce(this.webex, 'loaded', async () => {
1404
+ const warmed = await this._loadCatalogFromCache();
1405
+ if (warmed) {
1406
+ catalog.isReady = true;
1407
+ await this._finalizeReady();
1408
+
1409
+ return;
1410
+ }
1097
1411
  const {supertoken} = this.webex.credentials;
1412
+
1413
+ // Race init against a hard timeout so a hung request never leaves
1414
+ // `services.ready` false forever - that would stall `webex.ready` and
1415
+ // leave the app on a permanent spinner.
1416
+ const timeout = new Promise<never>((_, reject) => {
1417
+ setTimeout(
1418
+ () => reject(new Error(`services: init timed out after ${SERVICES_INIT_TIMEOUT_MS}ms`)),
1419
+ SERVICES_INIT_TIMEOUT_MS
1420
+ );
1421
+ });
1422
+
1098
1423
  // Validate if the supertoken exists.
1099
1424
  if (supertoken && supertoken.access_token) {
1100
- this.initServiceCatalogs()
1425
+ Promise.race([this.initServiceCatalogs(), timeout])
1101
1426
  .then(() => {
1102
1427
  catalog.isReady = true;
1103
1428
  })
@@ -1106,15 +1431,33 @@ const Services = WebexPlugin.extend({
1106
1431
  this.logger.error(
1107
1432
  `services: failed to init initial services when credentials available, ${error?.message}`
1108
1433
  );
1109
- });
1434
+ })
1435
+ .finally(() => this._finalizeReady());
1110
1436
  } else {
1111
1437
  const {email} = this.webex.config;
1112
1438
 
1113
- this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
1114
- this.initFailed = true;
1115
- this.logger.error(
1116
- `services: failed to init initial services when no credentials available, ${error?.message}`
1117
- );
1439
+ Promise.race([this.collectPreauthCatalog(email ? {email} : undefined), timeout])
1440
+ .catch((error) => {
1441
+ this.initFailed = true;
1442
+ this.logger.error(
1443
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1444
+ );
1445
+ })
1446
+ .finally(() => this._finalizeReady());
1447
+ // Listen for when credentials become available to fetch the full catalog.
1448
+ // This handles fresh login where 'loaded' fires before OAuth completes.
1449
+ this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1450
+ if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
1451
+ this.initServiceCatalogs()
1452
+ .then(() => {
1453
+ catalog.isReady = true;
1454
+ })
1455
+ .catch((error) => {
1456
+ this.logger.error(
1457
+ `services: failed to init service catalogs after auth, ${error?.message}`
1458
+ );
1459
+ });
1460
+ }
1118
1461
  });
1119
1462
  }
1120
1463
  });
@@ -2,6 +2,11 @@ type ServiceName = string;
2
2
  type ClusterId = string;
3
3
  export type ServiceGroup = 'discovery' | 'override' | 'preauth' | 'postauth' | 'signin';
4
4
 
5
+ export type SelectionMeta = {
6
+ selectionType: string;
7
+ selectionValue: string;
8
+ };
9
+
5
10
  export type ServiceHost = {
6
11
  host: string;
7
12
  ttl: number;
@@ -23,13 +23,12 @@ describe('webex-core', () => {
23
23
  .then(
24
24
  ([user]) =>
25
25
  new Promise((resolve) => {
26
- setTimeout(() => {
27
- webexUser = user;
28
- webex = new WebexCore({credentials: user.token});
29
- services = webex.internal.services;
30
- catalog = services._getCatalog();
31
- resolve();
32
- }, 1000);
26
+ webexUser = user;
27
+ webex = new WebexCore({credentials: user.token});
28
+ services = webex.internal.services;
29
+ catalog = services._getCatalog();
30
+ // Wait for webex ready event before registering device to ensure newMetrics.callDiagnosticMetrics is initialized
31
+ webex.once('ready', resolve);
33
32
  })
34
33
  )
35
34
  .then(() => webex.internal.device.register())
@@ -510,13 +509,19 @@ describe('webex-core', () => {
510
509
  );
511
510
 
512
511
  it('resolves to an authed u2c hostmap when no params specified', () => {
513
- assert.typeOf(fullRemoteHM, 'array');
514
- assert.isAbove(fullRemoteHM.length, 0);
512
+ assert.typeOf(fullRemoteHM, 'object');
513
+ assert.property(fullRemoteHM, 'serviceLinks');
514
+ assert.property(fullRemoteHM, 'hostCatalog');
515
+ assert.equal(fullRemoteHM.format, 'hostmap');
516
+ assert.isAbove(Object.keys(fullRemoteHM.serviceLinks).length, 0);
515
517
  });
516
518
 
517
519
  it('resolves to a limited u2c hostmap when params specified', () => {
518
- assert.typeOf(limitedRemoteHM, 'array');
519
- assert.isAbove(limitedRemoteHM.length, 0);
520
+ assert.typeOf(limitedRemoteHM, 'object');
521
+ assert.property(limitedRemoteHM, 'serviceLinks');
522
+ assert.property(limitedRemoteHM, 'hostCatalog');
523
+ assert.equal(limitedRemoteHM.format, 'hostmap');
524
+ assert.isAbove(Object.keys(limitedRemoteHM.serviceLinks).length, 0);
520
525
  });
521
526
 
522
527
  it('rejects if the params provided are invalid', () =>
@@ -404,12 +404,26 @@ describe('webex-core', () => {
404
404
  assert.isTrue(catalog.isReady);
405
405
  });
406
406
 
407
- it('should call services#initServiceCatalogs() on webex ready', () => {
407
+ it('should call services#initServiceCatalogs() on webex loaded', async () => {
408
408
  services.initServiceCatalogs = sinon.stub().resolves();
409
409
  services.initialize();
410
- webex.trigger('ready');
410
+ webex.trigger('loaded');
411
+ // Wait for the async callback to execute
412
+ await new Promise((resolve) => setTimeout(resolve, 10));
411
413
  assert.called(services.initServiceCatalogs);
412
- assert.isTrue(catalog.isReady);
414
+ });
415
+
416
+ it('should set services.ready to true after initialization completes', async () => {
417
+ // services.ready starts as false
418
+ const newWebex = new WebexCore({credentials: {supertoken: webexUser.token}});
419
+ const newServices = newWebex.internal.services;
420
+
421
+ // Wait for initialization to complete
422
+ await new Promise((resolve) => {
423
+ newServices.on('services:initialized', resolve);
424
+ });
425
+
426
+ assert.isTrue(newServices.ready);
413
427
  });
414
428
 
415
429
  it('should collect different catalogs based on OrgId region', () =>
@@ -697,7 +711,11 @@ describe('webex-core', () => {
697
711
 
698
712
  it('updates query.email to be emailhash-ed using SHA256', (done) => {
699
713
  catalog.updateServiceUrls = sinon.stub().returns({}); // returns `this`
700
- services._fetchNewServiceHostmap = sinon.stub().resolves();
714
+ services._fetchNewServiceHostmap = sinon.stub().resolves({
715
+ serviceLinks: {},
716
+ hostCatalog: {},
717
+ format: 'hostmap',
718
+ });
701
719
 
702
720
  services
703
721
  .updateServices({
@@ -827,9 +845,12 @@ describe('webex-core', () => {
827
845
 
828
846
  const getActivationRequest = (requestStub, useUserOnboarding = false) => {
829
847
  const expectedService = useUserOnboarding ? 'user-onboarding' : 'license';
830
- const expectedResource = useUserOnboarding ? 'api/v1/users/activations' : 'users/activations';
848
+ const expectedResource = useUserOnboarding
849
+ ? 'api/v1/users/activations'
850
+ : 'users/activations';
831
851
  const requests = requestStub.args.filter(
832
- ([request]) => request.service === expectedService && request.resource === expectedResource
852
+ ([request]) =>
853
+ request.service === expectedService && request.resource === expectedResource
833
854
  );
834
855
 
835
856
  assert.strictEqual(requests.length, 1);
@@ -908,7 +929,7 @@ describe('webex-core', () => {
908
929
  assert.equal(Object.keys(unauthServices.list(false, 'postauth')).length, 0);
909
930
  }));
910
931
 
911
- it.skip('validates new user with activationOptions suppressEmail true', () =>
932
+ it('validates new user with activationOptions suppressEmail true', () =>
912
933
  unauthServices
913
934
  .validateUser({
914
935
  email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
@@ -1254,13 +1275,19 @@ describe('webex-core', () => {
1254
1275
  );
1255
1276
 
1256
1277
  it('resolves to an authed u2c hostmap when no params specified', () => {
1257
- assert.typeOf(fullRemoteHM, 'array');
1258
- assert.isAbove(fullRemoteHM.length, 0);
1278
+ assert.typeOf(fullRemoteHM, 'object');
1279
+ assert.property(fullRemoteHM, 'serviceLinks');
1280
+ assert.property(fullRemoteHM, 'hostCatalog');
1281
+ assert.equal(fullRemoteHM.format, 'hostmap');
1282
+ assert.isAbove(Object.keys(fullRemoteHM.serviceLinks).length, 0);
1259
1283
  });
1260
1284
 
1261
1285
  it('resolves to a limited u2c hostmap when params specified', () => {
1262
- assert.typeOf(limitedRemoteHM, 'array');
1263
- assert.isAbove(limitedRemoteHM.length, 0);
1286
+ assert.typeOf(limitedRemoteHM, 'object');
1287
+ assert.property(limitedRemoteHM, 'serviceLinks');
1288
+ assert.property(limitedRemoteHM, 'hostCatalog');
1289
+ assert.equal(limitedRemoteHM.format, 'hostmap');
1290
+ assert.isAbove(Object.keys(limitedRemoteHM.serviceLinks).length, 0);
1264
1291
  });
1265
1292
 
1266
1293
  it('rejects if the params provided are invalid', () =>
@@ -23,7 +23,7 @@ import {
23
23
  formattedServiceHostmapEntryConv,
24
24
  formattedServiceHostmapEntryMercury,
25
25
  formattedServiceHostmapEntryTest,
26
- serviceHostmapV2
26
+ serviceHostmapV2,
27
27
  } from '../../../fixtures/host-catalog-v2';
28
28
 
29
29
  // /* eslint-disable no-underscore-dangle */
@@ -316,12 +316,26 @@ describe('webex-core', () => {
316
316
  assert.isTrue(catalog.isReady);
317
317
  });
318
318
 
319
- it('should call services#initServiceCatalogs() on webex ready', () => {
319
+ it('should call services#initServiceCatalogs() on webex loaded', async () => {
320
320
  services.initServiceCatalogs = sinon.stub().resolves();
321
321
  services.initialize();
322
- webex.trigger('ready');
322
+ webex.trigger('loaded');
323
+ // Wait for the async callback to execute
324
+ await new Promise((resolve) => setTimeout(resolve, 10));
323
325
  assert.called(services.initServiceCatalogs);
324
- assert.isTrue(catalog.isReady);
326
+ });
327
+
328
+ it('should set services.ready to true after initialization completes', async () => {
329
+ // services.ready starts as false
330
+ const newWebex = new WebexCore({credentials: {supertoken: webexUser.token}});
331
+ const newServices = newWebex.internal.services;
332
+
333
+ // Wait for initialization to complete
334
+ await new Promise((resolve) => {
335
+ newServices.on('services:initialized', resolve);
336
+ });
337
+
338
+ assert.isTrue(newServices.ready);
325
339
  });
326
340
 
327
341
  it('should collect different catalogs based on OrgId region', () =>
@@ -413,7 +427,11 @@ describe('webex-core', () => {
413
427
  .initServiceCatalogs(true)
414
428
  // services#updateServices() gets called once by the limited catalog
415
429
  // retrieval and should get called again when authorized.
416
- .then(() => assert.calledTwice(services.updateServices) && assert.calledWith(services.updateServices, sinon.match({forceRefresh: true})))
430
+ .then(
431
+ () =>
432
+ assert.calledTwice(services.updateServices) &&
433
+ assert.calledWith(services.updateServices, sinon.match({forceRefresh: true}))
434
+ )
417
435
  );
418
436
  });
419
437
  });
@@ -750,9 +768,12 @@ describe('webex-core', () => {
750
768
 
751
769
  const getActivationRequest = (requestStub, useUserOnboarding = false) => {
752
770
  const expectedService = useUserOnboarding ? 'user-onboarding' : 'license';
753
- const expectedResource = useUserOnboarding ? 'api/v1/users/activations' : 'users/activations';
771
+ const expectedResource = useUserOnboarding
772
+ ? 'api/v1/users/activations'
773
+ : 'users/activations';
754
774
  const requests = requestStub.args.filter(
755
- ([request]) => request.service === expectedService && request.resource === expectedResource
775
+ ([request]) =>
776
+ request.service === expectedService && request.resource === expectedResource
756
777
  );
757
778
 
758
779
  assert.strictEqual(requests.length, 1);
@@ -825,7 +846,7 @@ describe('webex-core', () => {
825
846
  assert.equal(r.user.verificationEmailTriggered, true);
826
847
  }));
827
848
 
828
- it.skip('validates new user with activationOptions suppressEmail true', () =>
849
+ it('validates new user with activationOptions suppressEmail true', () =>
829
850
  unauthServices
830
851
  .validateUser({
831
852
  email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,