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

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 +583 -94
  15. package/dist/lib/services/services.js.map +1 -1
  16. package/dist/lib/services-v2/services-v2.js +507 -41
  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 +433 -6
  30. package/src/lib/services-v2/services-v2.ts +417 -2
  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 +58 -9
  34. package/test/integration/spec/services-v2/services-v2.js +49 -6
  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 +692 -12
  39. package/test/unit/spec/services-v2/services-v2.ts +539 -0
  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,22 @@ const Services = WebexPlugin.extend({
40
50
  initFailed: ['boolean', false, false],
41
51
  },
42
52
 
53
+ session: {
54
+ /**
55
+ * Becomes `true` once the initial catalog collection has completed
56
+ * (successfully or otherwise) and any in-flight credentials refresh has
57
+ * settled. Blocks `webex.ready` so consumers can rely on `webex.ready`
58
+ * implying "catalogs populated AND credential state stable".
59
+ * @instance
60
+ * @memberof Services
61
+ * @type {boolean}
62
+ */
63
+ ready: {
64
+ default: false,
65
+ type: 'boolean',
66
+ },
67
+ },
68
+
43
69
  _catalogs: new WeakMap(),
44
70
 
45
71
  _activeServices: {},
@@ -56,6 +82,46 @@ const Services = WebexPlugin.extend({
56
82
  return this._catalogs.get(this.webex);
57
83
  },
58
84
 
85
+ /**
86
+ * Safely access localStorage if available; returns the Storage or null.
87
+ * @returns {Storage | null}
88
+ */
89
+ _getLocalStorageSafe(): Storage | null {
90
+ if (typeof window !== 'undefined' && (window as any).localStorage) {
91
+ return (window as any).localStorage as Storage;
92
+ }
93
+
94
+ return null;
95
+ },
96
+
97
+ /**
98
+ * Determine the intended preauth selection based on the current context.
99
+ * @param {string} [currentOrgId]
100
+ * @returns {{selectionType: string, selectionValue: string}}
101
+ */
102
+ getIntendedPreauthSelection(currentOrgId?: string): {
103
+ selectionType: string;
104
+ selectionValue: string;
105
+ } {
106
+ if (this.webex.credentials?.canAuthorize) {
107
+ if (currentOrgId) {
108
+ return {selectionType: 'orgId', selectionValue: currentOrgId};
109
+ }
110
+ }
111
+
112
+ const emailConfig = this.webex.config && this.webex.config.email;
113
+
114
+ if (typeof emailConfig === 'string' && emailConfig.trim()) {
115
+ return {
116
+ selectionType: 'emailhash',
117
+ selectionValue: sha256(emailConfig.toLowerCase()).toString(),
118
+ };
119
+ }
120
+
121
+ // fall back to proximity mode when no orgId or email available
122
+ return {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'};
123
+ },
124
+
59
125
  /**
60
126
  * Get a service url from the current services list by name
61
127
  * from the associated instance catalog.
@@ -110,6 +176,7 @@ const Services = WebexPlugin.extend({
110
176
  * @returns {Array<ServiceHost>} - An array of `ServiceHost` objects.
111
177
  */
112
178
  getMobiusClusters(): Array<ServiceHost> {
179
+ this.logger.info('services: fetching mobius clusters');
113
180
  const clusters: Array<ServiceHost> = [];
114
181
  const services: Array<Service> = this._services || [];
115
182
 
@@ -153,6 +220,27 @@ const Services = WebexPlugin.extend({
153
220
  });
154
221
  });
155
222
  },
223
+
224
+ /**
225
+ * Checks if the current environment is an integration (INT) environment
226
+ * by examining the u2c discovery URL from webex config.
227
+ * INT environments use discovery URLs containing 'intb' (e.g., u2c-intb.ciscospark.com).
228
+ * @returns {boolean} True if INT environment, false otherwise
229
+ */
230
+ isIntegrationEnvironment(): boolean {
231
+ try {
232
+ const u2cUrl = this.webex?.config?.services?.discovery?.u2c || '';
233
+ const isInt = u2cUrl.includes('intb');
234
+
235
+ this.logger.info(`services: isIntegrationEnvironment: ${isInt}`);
236
+
237
+ return isInt;
238
+ } catch (error) {
239
+ this.logger.error('services: failed to determine integration environment', error);
240
+
241
+ return false;
242
+ }
243
+ },
156
244
  /**
157
245
  * saves all the services from the pre and post catalog service
158
246
  * @param {ActiveServices} activeServices
@@ -249,6 +337,18 @@ const Services = WebexPlugin.extend({
249
337
  serviceHostMap?.services,
250
338
  serviceHostMap?.timestamp
251
339
  );
340
+ // Build selection metadata for caching discrimination (preauth/signin)
341
+ let selectionMeta: SelectionMeta | undefined;
342
+ if (serviceGroup === 'preauth' || serviceGroup === 'signin') {
343
+ const key = formattedQuery && Object.keys(formattedQuery || {})[0];
344
+ if (key) {
345
+ selectionMeta = {
346
+ selectionType: key,
347
+ selectionValue: formattedQuery[key],
348
+ };
349
+ }
350
+ }
351
+ this._cacheCatalog(serviceGroup, serviceHostMap, selectionMeta);
252
352
  this.updateCredentialsConfig();
253
353
  catalog.status[serviceGroup].collecting = false;
254
354
  })
@@ -934,6 +1034,190 @@ const Services = WebexPlugin.extend({
934
1034
  return url.replace(data.defaultUrl, data.priorityUrl);
935
1035
  },
936
1036
 
1037
+ /**
1038
+ * @private
1039
+ * Cache the catalog in the bounded storage.
1040
+ * @param {ServiceGroup} serviceGroup - preauth, signin, postauth
1041
+ * @param {ServiceHostmap} hostMap - The hostmap to cache
1042
+ * @param {object} [meta] - Optional selection metadata for cache discrimination
1043
+ * @returns {Promise<void>}
1044
+ */
1045
+ async _cacheCatalog(
1046
+ serviceGroup: ServiceGroup,
1047
+ hostMap: ServiceHostmap,
1048
+ meta?: SelectionMeta
1049
+ ): Promise<void> {
1050
+ let current: {orgId?: string; env?: {fedramp?: boolean; u2cDiscoveryUrl?: string}} = {};
1051
+ let orgId: string | undefined;
1052
+ try {
1053
+ // Respect calling.cacheU2C toggle; if disabled, skip writing cache
1054
+ if (!this.webex.config?.calling?.cacheU2C) {
1055
+ this.logger.info(`services: skipping cache write for ${serviceGroup} as per the config`);
1056
+
1057
+ return;
1058
+ }
1059
+
1060
+ try {
1061
+ const ls = this._getLocalStorageSafe();
1062
+ const cachedJson = ls ? ls.getItem(CATALOG_CACHE_KEY_V2) : null;
1063
+ current = cachedJson ? JSON.parse(cachedJson) : {};
1064
+ } catch {
1065
+ current = {};
1066
+ }
1067
+
1068
+ try {
1069
+ const {credentials} = this.webex;
1070
+ orgId = credentials.getOrgId();
1071
+ } catch {
1072
+ orgId = current.orgId;
1073
+ }
1074
+
1075
+ // Capture environment fingerprint to invalidate cache across env changes
1076
+ let {env} = current;
1077
+ const fedramp = !!this.webex?.config?.fedramp;
1078
+ const u2cDiscoveryUrl = this.webex?.config?.services?.discovery?.u2c;
1079
+ env = {fedramp, u2cDiscoveryUrl};
1080
+
1081
+ const updated = {
1082
+ ...current,
1083
+ orgId: orgId || current.orgId,
1084
+ env: env || current.env,
1085
+ // When selection meta is provided, store as an object; otherwise keep legacy shape
1086
+ [serviceGroup]: meta ? {hostMap, meta} : hostMap,
1087
+ cachedAt: Date.now(),
1088
+ };
1089
+
1090
+ const ls = this._getLocalStorageSafe();
1091
+ if (ls) {
1092
+ ls.setItem(CATALOG_CACHE_KEY_V2, JSON.stringify(updated));
1093
+ }
1094
+ } catch (e) {
1095
+ this.logger.warn('services: error caching catalog', e);
1096
+ }
1097
+ },
1098
+
1099
+ /**
1100
+ * @private
1101
+ * Load the catalog from cache and hydrate the in-memory ServiceCatalog.
1102
+ * @returns {Promise<boolean>} true if cache was loaded, false otherwise
1103
+ */
1104
+ async _loadCatalogFromCache(): Promise<boolean> {
1105
+ let currentOrgId: string | undefined;
1106
+ try {
1107
+ // Respect calling.cacheU2C toggle; if disabled, skip using cache
1108
+ if (!this.webex.config?.calling?.cacheU2C) {
1109
+ this.logger.info('services: skipping cache warm-up as per the cache config');
1110
+
1111
+ return false;
1112
+ }
1113
+
1114
+ const ls = this._getLocalStorageSafe();
1115
+ if (!ls) {
1116
+ this.logger.info('services: skipping cache warm-up as no localStorage is available');
1117
+
1118
+ return false;
1119
+ }
1120
+ const cachedJson = ls.getItem(CATALOG_CACHE_KEY_V2);
1121
+ const cached = cachedJson ? JSON.parse(cachedJson) : undefined;
1122
+ if (!cached) {
1123
+ return false;
1124
+ }
1125
+
1126
+ // TTL enforcement
1127
+ const cachedAt = Number(cached.cachedAt) || 0;
1128
+ if (!cachedAt || Date.now() - cachedAt > CATALOG_TTL_MS) {
1129
+ this.clearCatalogCache();
1130
+
1131
+ return false;
1132
+ }
1133
+
1134
+ // If authorized, ensure cached org matches
1135
+ try {
1136
+ if (this.webex.credentials?.canAuthorize) {
1137
+ const {credentials} = this.webex;
1138
+ currentOrgId = credentials.getOrgId();
1139
+ if (cached.orgId && cached.orgId !== currentOrgId) {
1140
+ return false;
1141
+ }
1142
+ }
1143
+ } catch (e) {
1144
+ this.logger.warn('services: error checking orgId', e);
1145
+ }
1146
+
1147
+ // Ensure cached environment matches current environment
1148
+
1149
+ const fedramp = !!this.webex.config?.fedramp;
1150
+ const u2cDiscoveryUrl = this.webex.config?.services?.discovery?.u2c;
1151
+ const currentEnv = {fedramp, u2cDiscoveryUrl};
1152
+ if (cached.env) {
1153
+ const sameEnv =
1154
+ cached.env.fedramp === currentEnv.fedramp &&
1155
+ cached.env.u2cDiscoveryUrl === currentEnv.u2cDiscoveryUrl;
1156
+ if (!sameEnv) {
1157
+ return false;
1158
+ }
1159
+ }
1160
+
1161
+ const catalog = this._getCatalog();
1162
+ const groups: Array<ServiceGroup> = ['preauth', 'signin', 'postauth'];
1163
+
1164
+ groups.forEach((serviceGroup) => {
1165
+ const cachedGroup = cached[serviceGroup];
1166
+ if (!cachedGroup) {
1167
+ return;
1168
+ }
1169
+
1170
+ // Support legacy (hostMap) and new ({hostMap, meta}) shapes
1171
+ const hostMap: ServiceHostmap =
1172
+ cachedGroup && cachedGroup.hostMap ? cachedGroup.hostMap : cachedGroup;
1173
+ const meta: SelectionMeta | undefined = cachedGroup?.meta;
1174
+
1175
+ if (serviceGroup === 'preauth' && meta) {
1176
+ // For proximity-based selection, always fetch fresh to respect IP/region changes
1177
+ if (meta.selectionType === 'mode') {
1178
+ return;
1179
+ }
1180
+
1181
+ const intended = this.getIntendedPreauthSelection(currentOrgId);
1182
+ const matches =
1183
+ intended &&
1184
+ intended.selectionType === meta.selectionType &&
1185
+ intended.selectionValue === meta.selectionValue;
1186
+ if (!matches) {
1187
+ return;
1188
+ }
1189
+ }
1190
+
1191
+ if (hostMap) {
1192
+ catalog.updateServiceGroups(serviceGroup, hostMap?.services, hostMap?.timestamp);
1193
+ }
1194
+ });
1195
+
1196
+ this.updateCredentialsConfig();
1197
+
1198
+ return true;
1199
+ } catch (e) {
1200
+ return false;
1201
+ }
1202
+ },
1203
+
1204
+ /**
1205
+ * Clear the catalog cache from the bounded storage (v2).
1206
+ * @returns {Promise<void>}
1207
+ */
1208
+ clearCatalogCache(): Promise<void> {
1209
+ try {
1210
+ const ls = this._getLocalStorageSafe();
1211
+ if (ls) {
1212
+ ls.removeItem(CATALOG_CACHE_KEY_V2);
1213
+ }
1214
+ } catch (e) {
1215
+ this.logger.warn('services: error clearing catalog cache', e);
1216
+ }
1217
+
1218
+ return Promise.resolve();
1219
+ },
1220
+
937
1221
  /**
938
1222
  * @private
939
1223
  * Simplified method wrapper for sending a request to get
@@ -1075,6 +1359,29 @@ const Services = WebexPlugin.extend({
1075
1359
  );
1076
1360
  },
1077
1361
 
1362
+ /**
1363
+ * Await any in-flight credentials refresh, then flip `services.ready` so
1364
+ * `webex.ready` can fire. Closes the parallel-refresh window: if a credential
1365
+ * refresh is in flight when initial catalog collection settles, we must not
1366
+ * signal ready until the refresh has resolved - otherwise downstream
1367
+ * consumers may observe `canAuthorize`/token state that is about to change
1368
+ * under them.
1369
+ *
1370
+ * @private
1371
+ * @returns {Promise<void>}
1372
+ */
1373
+ async _finalizeReady(): Promise<void> {
1374
+ const {credentials} = this.webex;
1375
+
1376
+ if (credentials && credentials.isRefreshing) {
1377
+ await new Promise<void>((resolve) => {
1378
+ credentials.once('change:isRefreshing', resolve);
1379
+ });
1380
+ }
1381
+
1382
+ this.ready = true;
1383
+ },
1384
+
1078
1385
  /**
1079
1386
  * Initializer
1080
1387
  *
@@ -1086,14 +1393,48 @@ const Services = WebexPlugin.extend({
1086
1393
  const catalog = new ServiceCatalog();
1087
1394
  this._catalogs.set(this.webex, catalog);
1088
1395
 
1089
- // Listen for configuration changes once.
1396
+ // Listen for configuration changes once. The config is not populated on the
1397
+ // webex instance until the `change:config` event fires, so any decision that
1398
+ // depends on config values (such as the gated-vs-ungated init below) must be
1399
+ // made from within this handler rather than synchronously in `initialize()`.
1090
1400
  this.listenToOnce(this.webex, 'change:config', () => {
1091
1401
  this.initConfig();
1402
+
1403
+ // Feature flag: when enabled, `webex.ready` is blocked until the initial
1404
+ // catalog collection has settled AND any in-flight credentials refresh has
1405
+ // completed. When disabled (the default), preserves the pre-existing
1406
+ // behavior where `webex.ready` fires as soon as `webex.loaded` does and
1407
+ // the catalog is collected out-of-band.
1408
+ const waitForCatalogInit = this.webex.config?.services?.waitForCatalogInit === true;
1409
+
1410
+ if (waitForCatalogInit) {
1411
+ this._initializeCatalogsGated(catalog);
1412
+ } else {
1413
+ // Not gating - immediately mark ready so we do not block webex.ready.
1414
+ this.ready = true;
1415
+ this._initializeCatalogsUngated(catalog);
1416
+ }
1092
1417
  });
1418
+ },
1093
1419
 
1420
+ /**
1421
+ * Original (pre-verified-ready) initialization path. Runs on `webex.ready`
1422
+ * and collects catalogs opportunistically without blocking anything.
1423
+ *
1424
+ * @private
1425
+ * @param {ServiceCatalog} catalog
1426
+ * @returns {void}
1427
+ */
1428
+ _initializeCatalogsUngated(catalog: ServiceCatalog): void {
1094
1429
  // wait for webex instance to be ready before attempting
1095
1430
  // to update the service catalogs
1096
- this.listenToOnce(this.webex, 'ready', () => {
1431
+ this.listenToOnce(this.webex, 'ready', async () => {
1432
+ const warmed = await this._loadCatalogFromCache();
1433
+ if (warmed) {
1434
+ catalog.isReady = true;
1435
+
1436
+ return;
1437
+ }
1097
1438
  const {supertoken} = this.webex.credentials;
1098
1439
  // Validate if the supertoken exists.
1099
1440
  if (supertoken && supertoken.access_token) {
@@ -1119,6 +1460,80 @@ const Services = WebexPlugin.extend({
1119
1460
  }
1120
1461
  });
1121
1462
  },
1463
+
1464
+ /**
1465
+ * Verified-ready initialization path. Blocks `webex.ready` until the initial
1466
+ * catalog fetch has settled (or timed out) AND any in-flight credentials
1467
+ * refresh has completed. Also handles the fresh-login case where OAuth
1468
+ * completes after `loaded` fires.
1469
+ *
1470
+ * @private
1471
+ * @param {ServiceCatalog} catalog
1472
+ * @returns {void}
1473
+ */
1474
+ _initializeCatalogsGated(catalog: ServiceCatalog): void {
1475
+ // Wait for storage to be loaded before attempting to update the service
1476
+ // catalogs. We listen for 'loaded' instead of 'ready' because `services.ready`
1477
+ // now blocks `webex.ready` - listening to 'ready' would deadlock.
1478
+ this.listenToOnce(this.webex, 'loaded', async () => {
1479
+ const warmed = await this._loadCatalogFromCache();
1480
+ if (warmed) {
1481
+ catalog.isReady = true;
1482
+ await this._finalizeReady();
1483
+
1484
+ return;
1485
+ }
1486
+ const {supertoken} = this.webex.credentials;
1487
+
1488
+ // Race init against a hard timeout so a hung request never leaves
1489
+ // `services.ready` false forever - that would stall `webex.ready` and
1490
+ // leave the app on a permanent spinner.
1491
+ const timeout = new Promise<never>((_, reject) => {
1492
+ setTimeout(
1493
+ () => reject(new Error(`services: init timed out after ${SERVICES_INIT_TIMEOUT_MS}ms`)),
1494
+ SERVICES_INIT_TIMEOUT_MS
1495
+ );
1496
+ });
1497
+
1498
+ // Validate if the supertoken exists.
1499
+ if (supertoken && supertoken.access_token) {
1500
+ Promise.race([this.initServiceCatalogs(), timeout])
1501
+ .then(() => {
1502
+ catalog.isReady = true;
1503
+ })
1504
+ .catch((error) => {
1505
+ this.initFailed = true;
1506
+ this.logger.error(
1507
+ `services: failed to init initial services when credentials available, ${error?.message}`
1508
+ );
1509
+ })
1510
+ .finally(() => this._finalizeReady());
1511
+ } else {
1512
+ const {email} = this.webex.config;
1513
+
1514
+ Promise.race([this.collectPreauthCatalog(email ? {email} : undefined), timeout])
1515
+ .catch((error) => {
1516
+ this.initFailed = true;
1517
+ this.logger.error(
1518
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1519
+ );
1520
+ })
1521
+ .finally(() => this._finalizeReady());
1522
+
1523
+ // Handle fresh login: 'loaded' fires before OAuth completes, so listen
1524
+ // for `canAuthorize` flipping true and then collect the postauth catalog.
1525
+ this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1526
+ if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
1527
+ this.initServiceCatalogs().catch((error) => {
1528
+ this.logger.error(
1529
+ `services: failed to init service catalogs after auth, ${error?.message}`
1530
+ );
1531
+ });
1532
+ }
1533
+ });
1534
+ }
1535
+ });
1536
+ },
1122
1537
  });
1123
1538
  /* eslint-enable no-underscore-dangle */
1124
1539
 
@@ -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,10 +404,13 @@ 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 ready', async () => {
408
+ services._loadCatalogFromCache = sinon.stub().resolves(false);
408
409
  services.initServiceCatalogs = sinon.stub().resolves();
409
410
  services.initialize();
410
411
  webex.trigger('ready');
412
+ // Wait for the async 'ready' handler to complete
413
+ await new Promise((resolve) => setTimeout(resolve, 50));
411
414
  assert.called(services.initServiceCatalogs);
412
415
  assert.isTrue(catalog.isReady);
413
416
  });
@@ -427,6 +430,39 @@ describe('webex-core', () => {
427
430
  done();
428
431
  }, 2000);
429
432
  });
433
+
434
+ it('blocks webex.ready until services.ready flips when waitForCatalogInit is enabled', async () => {
435
+ const gatedWebex = new WebexCore({
436
+ credentials: {supertoken: webexUser.token},
437
+ config: {services: {waitForCatalogInit: true}},
438
+ });
439
+
440
+ // Before init settles, webex.ready must be false because services.ready
441
+ // is a dependency and starts false in the gated path.
442
+ assert.isFalse(gatedWebex.internal.services.ready, 'services.ready should start false');
443
+ assert.isFalse(gatedWebex.ready, 'webex.ready should not fire while services.ready is false');
444
+
445
+ // Wait up to 30s for services init to complete and flip ready.
446
+ await new Promise((resolve, reject) => {
447
+ if (gatedWebex.internal.services.ready) {
448
+ resolve();
449
+
450
+ return;
451
+ }
452
+ const timer = setTimeout(
453
+ () => reject(new Error('timed out waiting for services.ready')),
454
+ 30_000
455
+ );
456
+
457
+ gatedWebex.internal.services.once('change:ready', () => {
458
+ clearTimeout(timer);
459
+ resolve();
460
+ });
461
+ });
462
+
463
+ assert.isTrue(gatedWebex.internal.services.ready, 'services.ready should flip true after init settles');
464
+ assert.isTrue(gatedWebex.ready, 'webex.ready should fire once services.ready flips');
465
+ });
430
466
  });
431
467
 
432
468
  describe('#initServiceCatalogs()', () => {
@@ -697,7 +733,11 @@ describe('webex-core', () => {
697
733
 
698
734
  it('updates query.email to be emailhash-ed using SHA256', (done) => {
699
735
  catalog.updateServiceUrls = sinon.stub().returns({}); // returns `this`
700
- services._fetchNewServiceHostmap = sinon.stub().resolves();
736
+ services._fetchNewServiceHostmap = sinon.stub().resolves({
737
+ serviceLinks: {},
738
+ hostCatalog: {},
739
+ format: 'hostmap',
740
+ });
701
741
 
702
742
  services
703
743
  .updateServices({
@@ -827,9 +867,12 @@ describe('webex-core', () => {
827
867
 
828
868
  const getActivationRequest = (requestStub, useUserOnboarding = false) => {
829
869
  const expectedService = useUserOnboarding ? 'user-onboarding' : 'license';
830
- const expectedResource = useUserOnboarding ? 'api/v1/users/activations' : 'users/activations';
870
+ const expectedResource = useUserOnboarding
871
+ ? 'api/v1/users/activations'
872
+ : 'users/activations';
831
873
  const requests = requestStub.args.filter(
832
- ([request]) => request.service === expectedService && request.resource === expectedResource
874
+ ([request]) =>
875
+ request.service === expectedService && request.resource === expectedResource
833
876
  );
834
877
 
835
878
  assert.strictEqual(requests.length, 1);
@@ -908,7 +951,7 @@ describe('webex-core', () => {
908
951
  assert.equal(Object.keys(unauthServices.list(false, 'postauth')).length, 0);
909
952
  }));
910
953
 
911
- it.skip('validates new user with activationOptions suppressEmail true', () =>
954
+ it('validates new user with activationOptions suppressEmail true', () =>
912
955
  unauthServices
913
956
  .validateUser({
914
957
  email: `Collabctg+webex-js-sdk-${uuid.v4()}@gmail.com`,
@@ -1254,13 +1297,19 @@ describe('webex-core', () => {
1254
1297
  );
1255
1298
 
1256
1299
  it('resolves to an authed u2c hostmap when no params specified', () => {
1257
- assert.typeOf(fullRemoteHM, 'array');
1258
- assert.isAbove(fullRemoteHM.length, 0);
1300
+ assert.typeOf(fullRemoteHM, 'object');
1301
+ assert.property(fullRemoteHM, 'serviceLinks');
1302
+ assert.property(fullRemoteHM, 'hostCatalog');
1303
+ assert.equal(fullRemoteHM.format, 'hostmap');
1304
+ assert.isAbove(Object.keys(fullRemoteHM.serviceLinks).length, 0);
1259
1305
  });
1260
1306
 
1261
1307
  it('resolves to a limited u2c hostmap when params specified', () => {
1262
- assert.typeOf(limitedRemoteHM, 'array');
1263
- assert.isAbove(limitedRemoteHM.length, 0);
1308
+ assert.typeOf(limitedRemoteHM, 'object');
1309
+ assert.property(limitedRemoteHM, 'serviceLinks');
1310
+ assert.property(limitedRemoteHM, 'hostCatalog');
1311
+ assert.equal(limitedRemoteHM.format, 'hostmap');
1312
+ assert.isAbove(Object.keys(limitedRemoteHM.serviceLinks).length, 0);
1264
1313
  });
1265
1314
 
1266
1315
  it('rejects if the params provided are invalid', () =>