@webex/internal-plugin-mercury 3.9.0 → 3.10.0-multi-llms.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.
@@ -75,6 +75,8 @@ describe('plugin-mercury', () => {
75
75
  webex.internal.services = {
76
76
  convertUrlToPriorityHostUrl: sinon.stub().returns(Promise.resolve('ws://example-2.com')),
77
77
  markFailedUrl: sinon.stub().returns(Promise.resolve()),
78
+ switchActiveClusterIds: sinon.stub(),
79
+ invalidateCache: sinon.stub(),
78
80
  };
79
81
  webex.internal.metrics.submitClientMetrics = sinon.stub();
80
82
  webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus = sinon.stub();
@@ -97,9 +99,32 @@ describe('plugin-mercury', () => {
97
99
  });
98
100
 
99
101
  mercury = webex.internal.mercury;
102
+ mercury.defaultSessionId = 'mercury-default-session';
100
103
  });
101
104
 
102
- afterEach(() => {
105
+ afterEach(async () => {
106
+ // Clean up Mercury connections and internal state
107
+ if (mercury) {
108
+ try {
109
+ await mercury.disconnectAll();
110
+ } catch (e) {
111
+ // Ignore cleanup errors
112
+ }
113
+ // Clear any remaining connection promises
114
+ if (mercury._connectPromises) {
115
+ mercury._connectPromises.clear();
116
+ }
117
+ }
118
+
119
+ // Ensure mock socket is properly closed
120
+ if (mockWebSocket && typeof mockWebSocket.close === 'function') {
121
+ try {
122
+ mockWebSocket.close();
123
+ } catch (e) {
124
+ // Ignore cleanup errors
125
+ }
126
+ }
127
+
103
128
  if (socketOpenStub) {
104
129
  socketOpenStub.restore();
105
130
  }
@@ -107,6 +132,9 @@ describe('plugin-mercury', () => {
107
132
  if (Socket.getWebSocketConstructor.restore) {
108
133
  Socket.getWebSocketConstructor.restore();
109
134
  }
135
+
136
+ // Small delay to ensure all async operations complete
137
+ await new Promise(resolve => setTimeout(resolve, 10));
110
138
  });
111
139
 
112
140
  describe('#listen()', () => {
@@ -162,7 +190,6 @@ describe('plugin-mercury', () => {
162
190
  },
163
191
  },
164
192
  };
165
-
166
193
  assert.isFalse(mercury.connected, 'Mercury is not connected');
167
194
  assert.isTrue(mercury.connecting, 'Mercury is connecting');
168
195
  mockWebSocket.open();
@@ -191,6 +218,67 @@ describe('plugin-mercury', () => {
191
218
  sinon.restore();
192
219
  });
193
220
  });
221
+ it('Mercury emit event:ActiveClusterStatusEvent, call services switchActiveClusterIds', () => {
222
+ const promise = mercury.connect();
223
+ const activeClusterEventEnvelope = {
224
+ data: {
225
+ activeClusters: {
226
+ wdm: 'wdm-cluster-id.com',
227
+ },
228
+ },
229
+ };
230
+ mockWebSocket.open();
231
+
232
+ return promise.then(() => {
233
+ mercury._emit('event:ActiveClusterStatusEvent', activeClusterEventEnvelope);
234
+ assert.calledOnceWithExactly(
235
+ webex.internal.services.switchActiveClusterIds,
236
+ activeClusterEventEnvelope.data.activeClusters
237
+ );
238
+ sinon.restore();
239
+ });
240
+ });
241
+ it('Mercury emit event:ActiveClusterStatusEvent with no data, not call services switchActiveClusterIds', () => {
242
+ webex.internal.feature.updateFeature = sinon.stub();
243
+ const promise = mercury.connect();
244
+ const envelope = {};
245
+
246
+ return promise.then(() => {
247
+ mercury._emit('event:ActiveClusterStatusEvent', envelope);
248
+ assert.notCalled(webex.internal.services.switchActiveClusterIds);
249
+ sinon.restore();
250
+ });
251
+ });
252
+ it('Mercury emit event:u2c.cache-invalidation, call services invalidateCache', () => {
253
+ const promise = mercury.connect();
254
+ const u2cInvalidateEventEnvelope = {
255
+ data: {
256
+ timestamp: '1759289614',
257
+ },
258
+ };
259
+
260
+ mockWebSocket.open();
261
+
262
+ return promise.then(() => {
263
+ mercury._emit('event:u2c.cache-invalidation', u2cInvalidateEventEnvelope);
264
+ assert.calledOnceWithExactly(
265
+ webex.internal.services.invalidateCache,
266
+ u2cInvalidateEventEnvelope.data.timestamp
267
+ );
268
+ sinon.restore();
269
+ });
270
+ });
271
+ it('Mercury emit event:u2c.cache-invalidation with no data, not call services switchActiveClusterIds', () => {
272
+ webex.internal.feature.updateFeature = sinon.stub();
273
+ const promise = mercury.connect();
274
+ const envelope = {};
275
+
276
+ return promise.then(() => {
277
+ mercury._emit('event:u2c.cache-invalidation', envelope);
278
+ assert.notCalled(webex.internal.services.invalidateCache);
279
+ sinon.restore();
280
+ });
281
+ });
194
282
 
195
283
  describe('when `maxRetries` is set', () => {
196
284
  const check = () => {
@@ -436,9 +524,13 @@ describe('plugin-mercury', () => {
436
524
 
437
525
  // skipping due to apparent bug with lolex in all browsers but Chrome.
438
526
  skipInBrowser(it)('does not continue attempting to connect', () => {
439
- mercury.connect();
527
+ const promise = mercury.connect();
528
+
529
+ // Wait for the connection to be established before proceeding
530
+ mockWebSocket.open();
440
531
 
441
- return promiseTick(2)
532
+ return promise.then(() =>
533
+ promiseTick(2)
442
534
  .then(() => {
443
535
  clock.tick(6 * webex.internal.mercury.config.backoffTimeReset);
444
536
 
@@ -446,7 +538,8 @@ describe('plugin-mercury', () => {
446
538
  })
447
539
  .then(() => {
448
540
  assert.calledOnce(Socket.prototype.open);
449
- });
541
+ })
542
+ );
450
543
  });
451
544
  });
452
545
 
@@ -521,11 +614,11 @@ describe('plugin-mercury', () => {
521
614
  });
522
615
 
523
616
  describe('#logout()', () => {
524
- it('calls disconnect and logs', () => {
617
+ it('calls disconnectAll and logs', () => {
525
618
  sinon.stub(mercury.logger, 'info');
526
- sinon.stub(mercury, 'disconnect');
619
+ sinon.stub(mercury, 'disconnectAll');
527
620
  mercury.logout();
528
- assert.called(mercury.disconnect);
621
+ assert.called(mercury.disconnectAll);
529
622
  assert.calledTwice(mercury.logger.info);
530
623
 
531
624
  assert.calledWith(mercury.logger.info.getCall(0), 'Mercury: logout() called');
@@ -537,24 +630,24 @@ describe('plugin-mercury', () => {
537
630
  });
538
631
 
539
632
  it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout', () => {
540
- sinon.stub(mercury, 'disconnect');
633
+ sinon.stub(mercury, 'disconnectAll');
541
634
  mercury.config.beforeLogoutOptionsCloseReason = 'done (permanent)';
542
635
  mercury.logout();
543
- assert.calledWith(mercury.disconnect, {code: 3050, reason: 'done (permanent)'});
636
+ assert.calledWith(mercury.disconnectAll, {code: 3050, reason: 'done (permanent)'});
544
637
  });
545
638
 
546
639
  it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout if the reason is different than standard', () => {
547
- sinon.stub(mercury, 'disconnect');
640
+ sinon.stub(mercury, 'disconnectAll');
548
641
  mercury.config.beforeLogoutOptionsCloseReason = 'test';
549
642
  mercury.logout();
550
- assert.calledWith(mercury.disconnect, {code: 3050, reason: 'test'});
643
+ assert.calledWith(mercury.disconnectAll, {code: 3050, reason: 'test'});
551
644
  });
552
645
 
553
646
  it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send undefined for logout if the reason is same as standard', () => {
554
- sinon.stub(mercury, 'disconnect');
647
+ sinon.stub(mercury, 'disconnectAll');
555
648
  mercury.config.beforeLogoutOptionsCloseReason = 'done (forced)';
556
649
  mercury.logout();
557
- assert.calledWith(mercury.disconnect, undefined);
650
+ assert.calledWith(mercury.disconnectAll, undefined);
558
651
  });
559
652
  });
560
653
 
@@ -660,12 +753,12 @@ describe('plugin-mercury', () => {
660
753
  return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
661
754
  // By this time backoffCall and mercury socket should be defined by the
662
755
  // 'connect' call
663
- assert.isDefined(mercury.backoffCall, 'Mercury backoffCall is not defined');
756
+ assert.isDefined(mercury.backoffCalls.get('mercury-default-session'), 'Mercury backoffCall is not defined');
664
757
  assert.isDefined(mercury.socket, 'Mercury socket is not defined');
665
758
  // Calling disconnect will abort the backoffCall, close the socket, and
666
759
  // reject the connect
667
760
  mercury.disconnect();
668
- assert.isUndefined(mercury.backoffCall, 'Mercury backoffCall is still defined');
761
+ assert.isUndefined(mercury.backoffCalls.get('mercury-default-session'), 'Mercury backoffCall is still defined');
669
762
  // The socket will never be unset (which seems bad)
670
763
  assert.isDefined(mercury.socket, 'Mercury socket is not defined');
671
764
 
@@ -683,15 +776,15 @@ describe('plugin-mercury', () => {
683
776
 
684
777
  let reason;
685
778
 
686
- mercury.backoffCall = undefined;
687
- mercury._attemptConnection('ws://example.com', (_reason) => {
779
+ mercury.backoffCalls.clear();
780
+ mercury._attemptConnection('ws://example.com', 'mercury-default-session',(_reason) => {
688
781
  reason = _reason;
689
782
  });
690
783
 
691
784
  return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
692
785
  assert.equal(
693
786
  reason.message,
694
- 'Mercury: prevent socket open when backoffCall no longer defined'
787
+ `Mercury: prevent socket open when backoffCall no longer defined for ${mercury.defaultSessionId}`
695
788
  );
696
789
  });
697
790
  });
@@ -713,7 +806,7 @@ describe('plugin-mercury', () => {
713
806
  return assert.isRejected(promise).then((error) => {
714
807
  const lastError = mercury.getLastError();
715
808
 
716
- assert.equal(error.message, 'Mercury Connection Aborted');
809
+ assert.equal(error.message, `Mercury Connection Aborted for ${mercury.defaultSessionId}`);
717
810
  assert.isDefined(lastError);
718
811
  assert.equal(lastError, realError);
719
812
  });
@@ -807,7 +900,7 @@ describe('plugin-mercury', () => {
807
900
  },
808
901
  };
809
902
  assert.isUndefined(mercury.mercuryTimeOffset);
810
- mercury._setTimeOffset(event);
903
+ mercury._setTimeOffset('mercury-default-session', event);
811
904
  assert.isDefined(mercury.mercuryTimeOffset);
812
905
  assert.isTrue(mercury.mercuryTimeOffset > 0);
813
906
  });
@@ -817,7 +910,7 @@ describe('plugin-mercury', () => {
817
910
  wsWriteTimestamp: Date.now() + 60000,
818
911
  },
819
912
  };
820
- mercury._setTimeOffset(event);
913
+ mercury._setTimeOffset('mercury-default-session', event);
821
914
  assert.isTrue(mercury.mercuryTimeOffset < 0);
822
915
  });
823
916
  it('handles invalid wsWriteTimestamp', () => {
@@ -828,7 +921,7 @@ describe('plugin-mercury', () => {
828
921
  wsWriteTimestamp: invalidTimestamp,
829
922
  },
830
923
  };
831
- mercury._setTimeOffset(event);
924
+ mercury._setTimeOffset('mercury-default-session', event);
832
925
  assert.isUndefined(mercury.mercuryTimeOffset);
833
926
  });
834
927
  });
@@ -922,5 +1015,626 @@ describe('plugin-mercury', () => {
922
1015
  });
923
1016
  });
924
1017
  });
1018
+
1019
+ describe('shutdown protocol', () => {
1020
+ describe('#_handleImminentShutdown()', () => {
1021
+ let connectWithBackoffStub;
1022
+
1023
+ beforeEach(() => {
1024
+ mercury.connected = true;
1025
+ mercury.socket = {
1026
+ url: 'ws://old-socket.com',
1027
+ removeAllListeners: sinon.stub(),
1028
+ };
1029
+ connectWithBackoffStub = sinon.stub(mercury, '_connectWithBackoff');
1030
+ connectWithBackoffStub.returns(Promise.resolve());
1031
+ sinon.stub(mercury, '_emit');
1032
+ });
1033
+
1034
+ afterEach(() => {
1035
+ connectWithBackoffStub.restore();
1036
+ mercury._emit.restore();
1037
+ });
1038
+
1039
+ it('should be idempotent - no-op if already in progress', () => {
1040
+ mercury._shutdownSwitchoverInProgress = true;
1041
+
1042
+ mercury._handleImminentShutdown();
1043
+
1044
+ assert.notCalled(connectWithBackoffStub);
1045
+ });
1046
+
1047
+ it('should set switchover flags when called', () => {
1048
+ mercury._handleImminentShutdown();
1049
+
1050
+ assert.isTrue(mercury._shutdownSwitchoverInProgress);
1051
+ assert.isDefined(mercury._shutdownSwitchoverId);
1052
+ });
1053
+
1054
+ it('should call _connectWithBackoff with correct parameters', (done) => {
1055
+ mercury._handleImminentShutdown();
1056
+
1057
+ process.nextTick(() => {
1058
+ assert.calledOnce(connectWithBackoffStub);
1059
+ const callArgs = connectWithBackoffStub.firstCall.args;
1060
+ assert.isUndefined(callArgs[0]); // webSocketUrl
1061
+ assert.isObject(callArgs[1]); // context
1062
+ assert.isTrue(callArgs[1].isShutdownSwitchover);
1063
+ done();
1064
+ });
1065
+ });
1066
+
1067
+ it('should handle exceptions during switchover', () => {
1068
+ connectWithBackoffStub.restore();
1069
+ sinon.stub(mercury, '_connectWithBackoff').throws(new Error('Connection failed'));
1070
+
1071
+ mercury._handleImminentShutdown();
1072
+
1073
+ assert.isFalse(mercury._shutdownSwitchoverInProgress);
1074
+ });
1075
+ });
1076
+
1077
+ describe('#_onmessage() with shutdown message', () => {
1078
+ beforeEach(() => {
1079
+ sinon.stub(mercury, '_handleImminentShutdown');
1080
+ sinon.stub(mercury, '_emit');
1081
+ sinon.stub(mercury, '_setTimeOffset');
1082
+ });
1083
+
1084
+ afterEach(() => {
1085
+ mercury._handleImminentShutdown.restore();
1086
+ mercury._emit.restore();
1087
+ mercury._setTimeOffset.restore();
1088
+ });
1089
+
1090
+ it('should trigger _handleImminentShutdown on shutdown message', () => {
1091
+ const shutdownEvent = {
1092
+ data: {
1093
+ type: 'shutdown',
1094
+ },
1095
+ };
1096
+
1097
+ const result = mercury._onmessage(shutdownEvent);
1098
+
1099
+ assert.calledOnce(mercury._handleImminentShutdown);
1100
+ assert.calledWith(mercury._emit, 'event:mercury_shutdown_imminent', shutdownEvent.data);
1101
+ assert.instanceOf(result, Promise);
1102
+ });
1103
+
1104
+ it('should handle shutdown message without additional data gracefully', () => {
1105
+ const shutdownEvent = {
1106
+ data: {
1107
+ type: 'shutdown',
1108
+ },
1109
+ };
1110
+
1111
+ mercury._onmessage(shutdownEvent);
1112
+
1113
+ assert.calledOnce(mercury._handleImminentShutdown);
1114
+ });
1115
+
1116
+ it('should not trigger shutdown handling for non-shutdown messages', () => {
1117
+ const regularEvent = {
1118
+ data: {
1119
+ type: 'regular',
1120
+ data: {
1121
+ eventType: 'conversation.activity',
1122
+ },
1123
+ },
1124
+ };
1125
+
1126
+ mercury._onmessage(regularEvent);
1127
+
1128
+ assert.notCalled(mercury._handleImminentShutdown);
1129
+ });
1130
+ });
1131
+
1132
+ describe('#_onclose() with code 4001 (shutdown replacement)', () => {
1133
+ let mockSocket, anotherSocket;
1134
+
1135
+ beforeEach(() => {
1136
+ mockSocket = {
1137
+ url: 'ws://active-socket.com',
1138
+ removeAllListeners: sinon.stub(),
1139
+ };
1140
+ anotherSocket = {
1141
+ url: 'ws://old-socket.com',
1142
+ removeAllListeners: sinon.stub(),
1143
+ };
1144
+ mercury.socket = mockSocket;
1145
+ mercury.connected = true;
1146
+ sinon.stub(mercury, '_emit');
1147
+ sinon.stub(mercury, '_reconnect');
1148
+ sinon.stub(mercury, 'unset');
1149
+ });
1150
+
1151
+ afterEach(() => {
1152
+ mercury._emit.restore();
1153
+ mercury._reconnect.restore();
1154
+ mercury.unset.restore();
1155
+ });
1156
+
1157
+ it('should handle active socket close with 4001 - permanent failure', () => {
1158
+ const closeEvent = {
1159
+ code: 4001,
1160
+ reason: 'replaced during shutdown',
1161
+ };
1162
+
1163
+ mercury._onclose(closeEvent, mockSocket);
1164
+
1165
+ assert.calledWith(mercury._emit, 'offline.permanent', closeEvent);
1166
+ assert.notCalled(mercury._reconnect); // No reconnect for 4001 on active socket
1167
+ assert.isFalse(mercury.connected);
1168
+ });
1169
+
1170
+ it('should handle non-active socket close with 4001 - no reconnect needed', () => {
1171
+ const closeEvent = {
1172
+ code: 4001,
1173
+ reason: 'replaced during shutdown',
1174
+ };
1175
+
1176
+ mercury._onclose(closeEvent, anotherSocket);
1177
+
1178
+ assert.calledWith(mercury._emit, 'offline.replaced', closeEvent);
1179
+ assert.notCalled(mercury._reconnect);
1180
+ assert.isTrue(mercury.connected); // Should remain connected
1181
+ assert.notCalled(mercury.unset);
1182
+ });
1183
+
1184
+ it('should distinguish between active and non-active socket closes', () => {
1185
+ const closeEvent = {
1186
+ code: 4001,
1187
+ reason: 'replaced during shutdown',
1188
+ };
1189
+
1190
+ // Test non-active socket
1191
+ mercury._onclose(closeEvent, anotherSocket);
1192
+ assert.calledWith(mercury._emit, 'offline.replaced', closeEvent);
1193
+
1194
+ // Reset the spy call history
1195
+ mercury._emit.resetHistory();
1196
+
1197
+ // Test active socket
1198
+ mercury._onclose(closeEvent, mockSocket);
1199
+ assert.calledWith(mercury._emit, 'offline.permanent', closeEvent);
1200
+ });
1201
+
1202
+ it('should handle missing sourceSocket parameter (treats as non-active)', () => {
1203
+ const closeEvent = {
1204
+ code: 4001,
1205
+ reason: 'replaced during shutdown',
1206
+ };
1207
+
1208
+ mercury._onclose(closeEvent); // No sourceSocket parameter
1209
+
1210
+ // With simplified logic, undefined !== this.socket, so isActiveSocket = false
1211
+ assert.calledWith(mercury._emit, 'offline.replaced', closeEvent);
1212
+ assert.notCalled(mercury._reconnect);
1213
+ });
1214
+
1215
+ it('should clean up event listeners from non-active socket when it closes', () => {
1216
+ const closeEvent = {
1217
+ code: 4001,
1218
+ reason: 'replaced during shutdown',
1219
+ };
1220
+
1221
+ // Close non-active socket (not the active one)
1222
+ mercury._onclose(closeEvent, anotherSocket);
1223
+
1224
+ // Verify listeners were removed from the old socket
1225
+ // The _onclose method checks if sourceSocket !== this.socket (non-active)
1226
+ // and then calls removeAllListeners in the else branch
1227
+ assert.calledOnce(anotherSocket.removeAllListeners);
1228
+ });
1229
+
1230
+ it('should not clean up listeners from active socket listeners until close handler runs', () => {
1231
+ const closeEvent = {
1232
+ code: 4001,
1233
+ reason: 'replaced during shutdown',
1234
+ };
1235
+
1236
+ // Close active socket
1237
+ mercury._onclose(closeEvent, mockSocket);
1238
+
1239
+ // Verify listeners were removed from active socket
1240
+ assert.calledOnce(mockSocket.removeAllListeners);
1241
+ });
1242
+ });
1243
+
1244
+ describe('shutdown switchover with retry logic', () => {
1245
+ let connectWithBackoffStub;
1246
+
1247
+ beforeEach(() => {
1248
+ mercury.connected = true;
1249
+ mercury.socket = {
1250
+ url: 'ws://old-socket.com',
1251
+ removeAllListeners: sinon.stub(),
1252
+ };
1253
+ connectWithBackoffStub = sinon.stub(mercury, '_connectWithBackoff');
1254
+ sinon.stub(mercury, '_emit');
1255
+ });
1256
+
1257
+ afterEach(() => {
1258
+ connectWithBackoffStub.restore();
1259
+ mercury._emit.restore();
1260
+ });
1261
+
1262
+ it('should call _connectWithBackoff with shutdown switchover context', (done) => {
1263
+ connectWithBackoffStub.returns(Promise.resolve());
1264
+
1265
+ mercury._handleImminentShutdown();
1266
+
1267
+ // Give it a tick for the async call to happen
1268
+ process.nextTick(() => {
1269
+ assert.calledOnce(connectWithBackoffStub);
1270
+ const callArgs = connectWithBackoffStub.firstCall.args;
1271
+
1272
+ assert.isUndefined(callArgs[0]); // webSocketUrl is undefined
1273
+ assert.isObject(callArgs[1]); // context object
1274
+ assert.isTrue(callArgs[1].isShutdownSwitchover);
1275
+ assert.isObject(callArgs[1].attemptOptions);
1276
+ assert.isTrue(callArgs[1].attemptOptions.isShutdownSwitchover);
1277
+ done();
1278
+ });
1279
+ });
1280
+
1281
+ it('should set _shutdownSwitchoverInProgress flag during switchover', () => {
1282
+ connectWithBackoffStub.returns(new Promise(() => {})); // Never resolves
1283
+
1284
+ mercury._handleImminentShutdown();
1285
+
1286
+ assert.isTrue(mercury._shutdownSwitchoverInProgress);
1287
+ });
1288
+
1289
+ it('should emit success event when switchover completes', async () => {
1290
+ // We need to actually call the onSuccess callback to trigger the event
1291
+ connectWithBackoffStub.callsFake((url, context) => {
1292
+ // Simulate successful connection by calling onSuccess
1293
+ if (context && context.attemptOptions && context.attemptOptions.onSuccess) {
1294
+ const mockSocket = {url: 'ws://new-socket.com'};
1295
+ context.attemptOptions.onSuccess(mockSocket, 'ws://new-socket.com');
1296
+ }
1297
+ return Promise.resolve();
1298
+ });
1299
+
1300
+ mercury._handleImminentShutdown();
1301
+
1302
+ // Wait for async operations
1303
+ await promiseTick(50);
1304
+
1305
+ const emitCalls = mercury._emit.getCalls();
1306
+ const hasCompleteEvent = emitCalls.some(
1307
+ (call) => call.args[0] === 'event:mercury_shutdown_switchover_complete'
1308
+ );
1309
+
1310
+ assert.isTrue(hasCompleteEvent, 'Should emit switchover complete event');
1311
+ });
1312
+
1313
+ it('should emit failure event when switchover exhausts retries', async () => {
1314
+ const testError = new Error('Connection failed');
1315
+
1316
+ connectWithBackoffStub.returns(Promise.reject(testError));
1317
+
1318
+ mercury._handleImminentShutdown();
1319
+ await promiseTick(50);
1320
+
1321
+ // Check if failure event was emitted
1322
+ const emitCalls = mercury._emit.getCalls();
1323
+ const hasFailureEvent = emitCalls.some(
1324
+ (call) =>
1325
+ call.args[0] === 'event:mercury_shutdown_switchover_failed' &&
1326
+ call.args[1] &&
1327
+ call.args[1].reason === testError
1328
+ );
1329
+
1330
+ assert.isTrue(hasFailureEvent, 'Should emit switchover failed event');
1331
+ });
1332
+
1333
+ it('should allow old socket to be closed by server after switchover failure', async () => {
1334
+ connectWithBackoffStub.returns(Promise.reject(new Error('Failed')));
1335
+
1336
+ mercury._handleImminentShutdown();
1337
+ await promiseTick(50);
1338
+
1339
+ // Old socket should not be closed immediately - server will close it
1340
+ assert.equal(mercury.socket.removeAllListeners.callCount, 0);
1341
+ });
1342
+ });
1343
+
1344
+ describe('#_prepareAndOpenSocket()', () => {
1345
+ let mockSocket, prepareUrlStub, getUserTokenStub;
1346
+
1347
+ beforeEach(() => {
1348
+ mockSocket = {
1349
+ open: sinon.stub().returns(Promise.resolve()),
1350
+ };
1351
+ prepareUrlStub = sinon
1352
+ .stub(mercury, '_prepareUrl')
1353
+ .returns(Promise.resolve('ws://example.com'));
1354
+ getUserTokenStub = webex.credentials.getUserToken;
1355
+ getUserTokenStub.returns(
1356
+ Promise.resolve({
1357
+ toString: () => 'mock-token',
1358
+ })
1359
+ );
1360
+ });
1361
+
1362
+ afterEach(() => {
1363
+ prepareUrlStub.restore();
1364
+ });
1365
+
1366
+ it('should prepare URL and get user token', async () => {
1367
+ await mercury._prepareAndOpenSocket(mockSocket, 'ws://test.com', false);
1368
+
1369
+ assert.calledOnce(prepareUrlStub);
1370
+ assert.calledWith(prepareUrlStub, 'ws://test.com');
1371
+ assert.calledOnce(getUserTokenStub);
1372
+ });
1373
+
1374
+ it('should open socket with correct options for normal connection', async () => {
1375
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1376
+
1377
+ assert.calledOnce(mockSocket.open);
1378
+ const callArgs = mockSocket.open.firstCall.args;
1379
+
1380
+ assert.equal(callArgs[0], 'ws://example.com');
1381
+ assert.isObject(callArgs[1]);
1382
+ assert.equal(callArgs[1].token, 'mock-token');
1383
+ assert.isDefined(callArgs[1].forceCloseDelay);
1384
+ assert.isDefined(callArgs[1].pingInterval);
1385
+ assert.isDefined(callArgs[1].pongTimeout);
1386
+ });
1387
+
1388
+ it('should log with correct prefix for normal connection', async () => {
1389
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1390
+
1391
+ // The method should complete successfully - we're testing it runs without error
1392
+ // Actual log message verification is complex due to existing stubs in parent scope
1393
+ assert.calledOnce(mockSocket.open);
1394
+ });
1395
+
1396
+ it('should log with shutdown prefix for shutdown connection', async () => {
1397
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, true);
1398
+
1399
+ // The method should complete successfully with shutdown flag
1400
+ assert.calledOnce(mockSocket.open);
1401
+ });
1402
+
1403
+ it('should merge custom mercury options when provided', async () => {
1404
+ webex.config.defaultMercuryOptions = {
1405
+ customOption: 'test-value',
1406
+ pingInterval: 99999,
1407
+ };
1408
+
1409
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1410
+
1411
+ const callArgs = mockSocket.open.firstCall.args;
1412
+
1413
+ assert.equal(callArgs[1].customOption, 'test-value');
1414
+ assert.equal(callArgs[1].pingInterval, 99999); // Custom value overrides default
1415
+ });
1416
+
1417
+ it('should return the webSocketUrl after opening', async () => {
1418
+ const result = await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1419
+
1420
+ assert.equal(result, 'ws://example.com');
1421
+ });
1422
+
1423
+ it('should handle errors during socket open', async () => {
1424
+ mockSocket.open.returns(Promise.reject(new Error('Open failed')));
1425
+
1426
+ try {
1427
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1428
+ assert.fail('Should have thrown an error');
1429
+ } catch (err) {
1430
+ assert.equal(err.message, 'Open failed');
1431
+ }
1432
+ });
1433
+ });
1434
+
1435
+ describe('#_attemptConnection() with shutdown switchover', () => {
1436
+ let mockSocket, prepareAndOpenSocketStub, callback;
1437
+
1438
+ beforeEach(() => {
1439
+ mockSocket = {
1440
+ url: 'ws://test.com',
1441
+ };
1442
+ prepareAndOpenSocketStub = sinon
1443
+ .stub(mercury, '_prepareAndOpenSocket')
1444
+ .returns(Promise.resolve('ws://new-socket.com'));
1445
+ callback = sinon.stub();
1446
+ mercury._shutdownSwitchoverBackoffCall = {}; // Mock backoff call
1447
+ mercury.socket = mockSocket;
1448
+ mercury.connected = true;
1449
+ sinon.stub(mercury, '_emit');
1450
+ sinon.stub(mercury, '_attachSocketEventListeners');
1451
+ });
1452
+
1453
+ afterEach(() => {
1454
+ prepareAndOpenSocketStub.restore();
1455
+ mercury._emit.restore();
1456
+ mercury._attachSocketEventListeners.restore();
1457
+ });
1458
+
1459
+ it('should not set socket reference before opening for shutdown switchover', async () => {
1460
+ const originalSocket = mercury.socket;
1461
+
1462
+ await mercury._attemptConnection('ws://test.com', callback, {
1463
+ isShutdownSwitchover: true,
1464
+ onSuccess: (newSocket, url) => {
1465
+ // During onSuccess, verify original socket is still set
1466
+ // (socket swap happens inside onSuccess callback in _handleImminentShutdown)
1467
+ assert.equal(mercury.socket, originalSocket);
1468
+ },
1469
+ });
1470
+
1471
+ // After onSuccess, socket should still be original since we only swap in _handleImminentShutdown
1472
+ assert.equal(mercury.socket, originalSocket);
1473
+ });
1474
+
1475
+ it('should call onSuccess callback with new socket and URL for shutdown', async () => {
1476
+ const onSuccessStub = sinon.stub();
1477
+
1478
+ await mercury._attemptConnection('ws://test.com', callback, {
1479
+ isShutdownSwitchover: true,
1480
+ onSuccess: onSuccessStub,
1481
+ });
1482
+
1483
+ assert.calledOnce(onSuccessStub);
1484
+ assert.equal(onSuccessStub.firstCall.args[1], 'ws://new-socket.com');
1485
+ });
1486
+
1487
+ it('should emit shutdown switchover complete event', async () => {
1488
+ const oldSocket = mercury.socket;
1489
+
1490
+ await mercury._attemptConnection('ws://test.com', callback, {
1491
+ isShutdownSwitchover: true,
1492
+ onSuccess: (newSocket, url) => {
1493
+ // Simulate the onSuccess callback behavior
1494
+ mercury.socket = newSocket;
1495
+ mercury.connected = true;
1496
+ mercury._emit('event:mercury_shutdown_switchover_complete', {url});
1497
+ },
1498
+ });
1499
+
1500
+ assert.calledWith(
1501
+ mercury._emit,
1502
+ 'event:mercury_shutdown_switchover_complete',
1503
+ sinon.match.has('url', 'ws://new-socket.com')
1504
+ );
1505
+ });
1506
+
1507
+ it('should use simpler error handling for shutdown switchover failures', async () => {
1508
+ prepareAndOpenSocketStub.returns(Promise.reject(new Error('Connection failed')));
1509
+
1510
+ try {
1511
+ await mercury._attemptConnection('ws://test.com', callback, {
1512
+ isShutdownSwitchover: true,
1513
+ });
1514
+ } catch (err) {
1515
+ // Error should be caught and passed to callback
1516
+ }
1517
+
1518
+ // Should call callback with error for retry
1519
+ assert.calledOnce(callback);
1520
+ assert.instanceOf(callback.firstCall.args[0], Error);
1521
+ });
1522
+
1523
+ it('should check _shutdownSwitchoverBackoffCall for shutdown connections', () => {
1524
+ mercury._shutdownSwitchoverBackoffCall = undefined;
1525
+
1526
+ const result = mercury._attemptConnection('ws://test.com', callback, {
1527
+ isShutdownSwitchover: true,
1528
+ });
1529
+
1530
+ return result.catch((err) => {
1531
+ assert.instanceOf(err, Error);
1532
+ assert.match(err.message, /switchover backoff call/);
1533
+ });
1534
+ });
1535
+ });
1536
+
1537
+ describe('#_connectWithBackoff() with shutdown switchover', () => {
1538
+ // Note: These tests verify the parameterization logic without running real backoff timers
1539
+ // to avoid test hangs. The backoff mechanism itself is tested in other test suites.
1540
+
1541
+ it('should use shutdown-specific parameters when called', () => {
1542
+ // Stub _connectWithBackoff to prevent real execution
1543
+ const connectWithBackoffStub = sinon
1544
+ .stub(mercury, '_connectWithBackoff')
1545
+ .returns(Promise.resolve());
1546
+
1547
+ mercury._handleImminentShutdown();
1548
+
1549
+ // Verify it was called with shutdown context
1550
+ assert.calledOnce(connectWithBackoffStub);
1551
+ const callArgs = connectWithBackoffStub.firstCall.args;
1552
+ assert.isObject(callArgs[1]); // context
1553
+ assert.isTrue(callArgs[1].isShutdownSwitchover);
1554
+
1555
+ connectWithBackoffStub.restore();
1556
+ });
1557
+
1558
+ it('should pass shutdown switchover options to _attemptConnection', () => {
1559
+ // Stub _attemptConnection to verify it receives correct options
1560
+ const attemptStub = sinon.stub(mercury, '_attemptConnection');
1561
+ attemptStub.callsFake((url, callback) => {
1562
+ // Immediately succeed
1563
+ callback();
1564
+ });
1565
+
1566
+ // Call _connectWithBackoff with shutdown context
1567
+ const context = {
1568
+ isShutdownSwitchover: true,
1569
+ attemptOptions: {
1570
+ isShutdownSwitchover: true,
1571
+ onSuccess: () => {},
1572
+ },
1573
+ };
1574
+
1575
+ // Start the backoff
1576
+ const promise = mercury._connectWithBackoff(undefined, context);
1577
+
1578
+ // Check that _attemptConnection was called with shutdown options
1579
+ return promise.then(() => {
1580
+ assert.calledOnce(attemptStub);
1581
+ const callArgs = attemptStub.firstCall.args;
1582
+ assert.isObject(callArgs[2]); // options parameter
1583
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1584
+
1585
+ attemptStub.restore();
1586
+ });
1587
+ });
1588
+
1589
+ it('should set and clear state flags appropriately', () => {
1590
+ // Stub to prevent actual connection
1591
+ sinon.stub(mercury, '_attemptConnection').callsFake((url, callback) => callback());
1592
+
1593
+ mercury._shutdownSwitchoverInProgress = true;
1594
+
1595
+ const promise = mercury._connectWithBackoff(undefined, {
1596
+ isShutdownSwitchover: true,
1597
+ attemptOptions: {isShutdownSwitchover: true, onSuccess: () => {}},
1598
+ });
1599
+
1600
+ return promise.then(() => {
1601
+ // Should be cleared after completion
1602
+ assert.isFalse(mercury._shutdownSwitchoverInProgress);
1603
+ mercury._attemptConnection.restore();
1604
+ });
1605
+ });
1606
+ });
1607
+
1608
+ describe('#disconnect() with shutdown switchover in progress', () => {
1609
+ let abortStub;
1610
+
1611
+ beforeEach(() => {
1612
+ mercury.socket = {
1613
+ close: sinon.stub().returns(Promise.resolve()),
1614
+ removeAllListeners: sinon.stub(),
1615
+ };
1616
+ abortStub = sinon.stub();
1617
+ mercury._shutdownSwitchoverBackoffCall = {
1618
+ abort: abortStub,
1619
+ };
1620
+ });
1621
+
1622
+ it('should abort shutdown switchover backoff call on disconnect', async () => {
1623
+ await mercury.disconnect();
1624
+
1625
+ assert.calledOnce(abortStub);
1626
+ });
1627
+
1628
+ it('should handle disconnect when no switchover is in progress', async () => {
1629
+ mercury._shutdownSwitchoverBackoffCall = undefined;
1630
+
1631
+ // Should not throw
1632
+ await mercury.disconnect();
1633
+
1634
+ // Should still close the socket
1635
+ assert.calledOnce(mercury.socket.close);
1636
+ });
1637
+ });
1638
+ });
925
1639
  });
926
1640
  });