@webex/internal-plugin-mercury 3.11.0 → 3.12.0-auth-prejoin-fetch.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.
@@ -100,9 +100,32 @@ describe('plugin-mercury', () => {
100
100
  });
101
101
 
102
102
  mercury = webex.internal.mercury;
103
+ mercury.defaultSessionId = 'mercury-default-session';
103
104
  });
104
105
 
105
- afterEach(() => {
106
+ afterEach(async () => {
107
+ // Clean up Mercury connections and internal state
108
+ if (mercury) {
109
+ try {
110
+ await mercury.disconnectAll();
111
+ } catch (e) {
112
+ // Ignore cleanup errors
113
+ }
114
+ // Clear any remaining connection promises
115
+ if (mercury._connectPromises) {
116
+ mercury._connectPromises.clear();
117
+ }
118
+ }
119
+
120
+ // Ensure mock socket is properly closed
121
+ if (mockWebSocket && typeof mockWebSocket.close === 'function') {
122
+ try {
123
+ mockWebSocket.close();
124
+ } catch (e) {
125
+ // Ignore cleanup errors
126
+ }
127
+ }
128
+
106
129
  if (socketOpenStub) {
107
130
  socketOpenStub.restore();
108
131
  }
@@ -110,6 +133,9 @@ describe('plugin-mercury', () => {
110
133
  if (Socket.getWebSocketConstructor.restore) {
111
134
  Socket.getWebSocketConstructor.restore();
112
135
  }
136
+
137
+ // Small delay to ensure all async operations complete
138
+ await new Promise((resolve) => setTimeout(resolve, 10));
113
139
  });
114
140
 
115
141
  describe('#listen()', () => {
@@ -268,18 +294,20 @@ describe('plugin-mercury', () => {
268
294
  .then(() => {
269
295
  assert.calledOnce(Socket.prototype.open);
270
296
 
271
- return promiseTick(5);
297
+ // Wait for error handling from 1st failure to schedule 2nd timer
298
+ return promiseTick(10);
272
299
  })
273
300
  .then(() => {
274
301
  clock.tick(mercury.config.backoffTimeReset);
275
302
 
276
- return promiseTick(5);
303
+ // Wait for 2nd socket.open call AND error handling to schedule 3rd timer
304
+ return promiseTick(10);
277
305
  })
278
306
  .then(() => {
279
307
  assert.calledTwice(Socket.prototype.open);
280
308
  clock.tick(2 * mercury.config.backoffTimeReset);
281
309
 
282
- return promiseTick(5);
310
+ return promiseTick(10);
283
311
  })
284
312
  .then(() => {
285
313
  assert.calledThrice(Socket.prototype.open);
@@ -354,20 +382,20 @@ describe('plugin-mercury', () => {
354
382
  .then(() => {
355
383
  assert.calledOnce(Socket.prototype.open);
356
384
 
357
- // I'm not sure why, but it's important the clock doesn't advance
358
- // until a tick happens
359
- return promiseTick(5);
385
+ // Wait for error handling from 1st failure to schedule 2nd timer
386
+ return promiseTick(10);
360
387
  })
361
388
  .then(() => {
362
389
  clock.tick(mercury.config.backoffTimeReset);
363
390
 
364
- return promiseTick(5);
391
+ // Wait for 2nd socket.open call AND error handling to schedule 3rd timer
392
+ return promiseTick(10);
365
393
  })
366
394
  .then(() => {
367
395
  assert.calledTwice(Socket.prototype.open);
368
396
  clock.tick(2 * mercury.config.backoffTimeReset);
369
397
 
370
- return promiseTick(5);
398
+ return promiseTick(10);
371
399
  })
372
400
  .then(() => {
373
401
  assert.calledThrice(Socket.prototype.open);
@@ -467,15 +495,49 @@ describe('plugin-mercury', () => {
467
495
  // });
468
496
 
469
497
  describe('when web-high-availability feature is enabled', () => {
470
- it('marks current socket url as failed and get new one on Connection Error', () => {
498
+ it('marks the URL as failed when socket.open() fails with a ConnectionError', () => {
499
+ // When socket.open() fails, _prepareAndOpenSocket attaches webSocketUrl to the error.
500
+ // The error handler then uses wsUrl = newWSUrl || reason.webSocketUrl, so markFailedUrl
501
+ // is called with the URL obtained before the socket open attempt.
471
502
  webex.internal.feature.getFeature.returns(Promise.resolve(true));
472
503
  socketOpenStub.restore();
473
504
  socketOpenStub = sinon.stub(Socket.prototype, 'open').returns(Promise.resolve());
474
505
  socketOpenStub.onCall(0).returns(Promise.reject(new ConnectionError({code: 4001})));
475
506
  const promise = mercury.connect();
476
507
 
477
- return promiseTick(7).then(() => {
508
+ return promiseTick(15).then(() => {
478
509
  assert.calledOnce(webex.internal.services.markFailedUrl);
510
+ assert.calledWith(
511
+ webex.internal.services.markFailedUrl,
512
+ sinon.match(/example-2\.com/)
513
+ );
514
+ clock.tick(1000);
515
+
516
+ return promise;
517
+ });
518
+ });
519
+
520
+ it('does not mark a URL as failed when no wsUrl is available in ConnectionError', () => {
521
+ // If the error has no webSocketUrl and newWSUrl is also undefined,
522
+ // markFailedUrl should not be called.
523
+ webex.internal.feature.getFeature.returns(Promise.resolve(true));
524
+
525
+ const errWithNoUrl = new ConnectionError({code: 4001});
526
+ // Explicitly ensure no webSocketUrl on the error
527
+ delete errWithNoUrl.webSocketUrl;
528
+
529
+ // Stub _prepareAndOpenSocket so it rejects without attaching webSocketUrl
530
+ // (simulating a failure before socket.open is reached, e.g. _prepareUrl failure)
531
+ const prepareAndOpenStub = sinon.stub(mercury, '_prepareAndOpenSocket');
532
+
533
+ prepareAndOpenStub.onCall(0).returns(Promise.reject(errWithNoUrl));
534
+ prepareAndOpenStub.returns(Promise.resolve('ws://example-2.com'));
535
+
536
+ const promise = mercury.connect();
537
+
538
+ return promiseTick(15).then(() => {
539
+ assert.notCalled(webex.internal.services.markFailedUrl);
540
+ prepareAndOpenStub.restore();
479
541
  clock.tick(1000);
480
542
 
481
543
  return promise;
@@ -499,17 +561,22 @@ describe('plugin-mercury', () => {
499
561
 
500
562
  // skipping due to apparent bug with lolex in all browsers but Chrome.
501
563
  skipInBrowser(it)('does not continue attempting to connect', () => {
502
- mercury.connect();
564
+ const promise = mercury.connect();
503
565
 
504
- return promiseTick(2)
505
- .then(() => {
506
- clock.tick(6 * webex.internal.mercury.config.backoffTimeReset);
566
+ // Wait for the connection to be established before proceeding
567
+ mockWebSocket.open();
507
568
 
508
- return promiseTick(2);
509
- })
510
- .then(() => {
511
- assert.calledOnce(Socket.prototype.open);
512
- });
569
+ return promise.then(() =>
570
+ promiseTick(2)
571
+ .then(() => {
572
+ clock.tick(6 * webex.internal.mercury.config.backoffTimeReset);
573
+
574
+ return promiseTick(2);
575
+ })
576
+ .then(() => {
577
+ assert.calledOnce(Socket.prototype.open);
578
+ })
579
+ );
513
580
  });
514
581
  });
515
582
 
@@ -584,11 +651,11 @@ describe('plugin-mercury', () => {
584
651
  });
585
652
 
586
653
  describe('#logout()', () => {
587
- it('calls disconnect and logs', () => {
654
+ it('calls disconnectAll and logs', () => {
588
655
  sinon.stub(mercury.logger, 'info');
589
- sinon.stub(mercury, 'disconnect');
656
+ sinon.stub(mercury, 'disconnectAll');
590
657
  mercury.logout();
591
- assert.called(mercury.disconnect);
658
+ assert.called(mercury.disconnectAll);
592
659
  assert.calledTwice(mercury.logger.info);
593
660
 
594
661
  assert.calledWith(mercury.logger.info.getCall(0), 'Mercury: logout() called');
@@ -600,24 +667,24 @@ describe('plugin-mercury', () => {
600
667
  });
601
668
 
602
669
  it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout', () => {
603
- sinon.stub(mercury, 'disconnect');
670
+ sinon.stub(mercury, 'disconnectAll');
604
671
  mercury.config.beforeLogoutOptionsCloseReason = 'done (permanent)';
605
672
  mercury.logout();
606
- assert.calledWith(mercury.disconnect, {code: 3050, reason: 'done (permanent)'});
673
+ assert.calledWith(mercury.disconnectAll, {code: 3050, reason: 'done (permanent)'});
607
674
  });
608
675
 
609
676
  it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout if the reason is different than standard', () => {
610
- sinon.stub(mercury, 'disconnect');
677
+ sinon.stub(mercury, 'disconnectAll');
611
678
  mercury.config.beforeLogoutOptionsCloseReason = 'test';
612
679
  mercury.logout();
613
- assert.calledWith(mercury.disconnect, {code: 3050, reason: 'test'});
680
+ assert.calledWith(mercury.disconnectAll, {code: 3050, reason: 'test'});
614
681
  });
615
682
 
616
683
  it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send undefined for logout if the reason is same as standard', () => {
617
- sinon.stub(mercury, 'disconnect');
684
+ sinon.stub(mercury, 'disconnectAll');
618
685
  mercury.config.beforeLogoutOptionsCloseReason = 'done (forced)';
619
686
  mercury.logout();
620
- assert.calledWith(mercury.disconnect, undefined);
687
+ assert.calledWith(mercury.disconnectAll, undefined);
621
688
  });
622
689
  });
623
690
 
@@ -723,12 +790,18 @@ describe('plugin-mercury', () => {
723
790
  return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
724
791
  // By this time backoffCall and mercury socket should be defined by the
725
792
  // 'connect' call
726
- assert.isDefined(mercury.backoffCall, 'Mercury backoffCall is not defined');
793
+ assert.isDefined(
794
+ mercury.backoffCalls.get('mercury-default-session'),
795
+ 'Mercury backoffCall is not defined'
796
+ );
727
797
  assert.isDefined(mercury.socket, 'Mercury socket is not defined');
728
798
  // Calling disconnect will abort the backoffCall, close the socket, and
729
799
  // reject the connect
730
800
  mercury.disconnect();
731
- assert.isUndefined(mercury.backoffCall, 'Mercury backoffCall is still defined');
801
+ assert.isUndefined(
802
+ mercury.backoffCalls.get('mercury-default-session'),
803
+ 'Mercury backoffCall is still defined'
804
+ );
732
805
  // The socket will never be unset (which seems bad)
733
806
  assert.isDefined(mercury.socket, 'Mercury socket is not defined');
734
807
 
@@ -746,16 +819,24 @@ describe('plugin-mercury', () => {
746
819
 
747
820
  let reason;
748
821
 
749
- mercury.backoffCall = undefined;
750
- mercury._attemptConnection('ws://example.com', (_reason) => {
751
- reason = _reason;
752
- });
822
+ mercury.backoffCalls.clear();
823
+
824
+ const promise = mercury._attemptConnection(
825
+ 'ws://example.com',
826
+ 'mercury-default-session',
827
+ (_reason) => {
828
+ reason = _reason;
829
+ }
830
+ );
753
831
 
754
832
  return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
755
833
  assert.equal(
756
834
  reason.message,
757
- 'Mercury: prevent socket open when backoffCall no longer defined'
835
+ `Mercury: prevent socket open when backoffCall no longer defined for ${mercury.defaultSessionId}`
758
836
  );
837
+
838
+ // Ensure the promise was actually rejected (short-circuited)
839
+ return assert.isRejected(promise);
759
840
  });
760
841
  });
761
842
 
@@ -776,7 +857,10 @@ describe('plugin-mercury', () => {
776
857
  return assert.isRejected(promise).then((error) => {
777
858
  const lastError = mercury.getLastError();
778
859
 
779
- assert.equal(error.message, 'Mercury Connection Aborted');
860
+ assert.equal(
861
+ error.message,
862
+ `Mercury Connection Aborted for ${mercury.defaultSessionId}`
863
+ );
780
864
  assert.isDefined(lastError);
781
865
  assert.equal(lastError, realError);
782
866
  });
@@ -870,7 +954,7 @@ describe('plugin-mercury', () => {
870
954
  },
871
955
  };
872
956
  assert.isUndefined(mercury.mercuryTimeOffset);
873
- mercury._setTimeOffset(event);
957
+ mercury._setTimeOffset('mercury-default-session', event);
874
958
  assert.isDefined(mercury.mercuryTimeOffset);
875
959
  assert.isTrue(mercury.mercuryTimeOffset > 0);
876
960
  });
@@ -880,7 +964,7 @@ describe('plugin-mercury', () => {
880
964
  wsWriteTimestamp: Date.now() + 60000,
881
965
  },
882
966
  };
883
- mercury._setTimeOffset(event);
967
+ mercury._setTimeOffset('mercury-default-session', event);
884
968
  assert.isTrue(mercury.mercuryTimeOffset < 0);
885
969
  });
886
970
  it('handles invalid wsWriteTimestamp', () => {
@@ -891,7 +975,7 @@ describe('plugin-mercury', () => {
891
975
  wsWriteTimestamp: invalidTimestamp,
892
976
  },
893
977
  };
894
- mercury._setTimeOffset(event);
978
+ mercury._setTimeOffset('mercury-default-session', event);
895
979
  assert.isUndefined(mercury.mercuryTimeOffset);
896
980
  });
897
981
  });
@@ -998,13 +1082,15 @@ describe('plugin-mercury', () => {
998
1082
  describe('shutdown protocol', () => {
999
1083
  describe('#_handleImminentShutdown()', () => {
1000
1084
  let connectWithBackoffStub;
1085
+ const sessionId = 'mercury-default-session';
1001
1086
 
1002
1087
  beforeEach(() => {
1003
1088
  mercury.connected = true;
1004
- mercury.socket = {
1089
+ mercury.sockets.set(sessionId, {
1005
1090
  url: 'ws://old-socket.com',
1006
1091
  removeAllListeners: sinon.stub(),
1007
- };
1092
+ });
1093
+ mercury.socket = mercury.sockets.get(sessionId);
1008
1094
  connectWithBackoffStub = sinon.stub(mercury, '_connectWithBackoff');
1009
1095
  connectWithBackoffStub.returns(Promise.resolve());
1010
1096
  sinon.stub(mercury, '_emit');
@@ -1013,32 +1099,47 @@ describe('plugin-mercury', () => {
1013
1099
  afterEach(() => {
1014
1100
  connectWithBackoffStub.restore();
1015
1101
  mercury._emit.restore();
1102
+ mercury.sockets.clear();
1016
1103
  });
1017
1104
 
1018
1105
  it('should be idempotent - no-op if already in progress', () => {
1019
- mercury._shutdownSwitchoverInProgress = true;
1106
+ // Simulate an existing switchover in progress by seeding the backoff map
1107
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {placeholder: true});
1020
1108
 
1021
- mercury._handleImminentShutdown();
1109
+ mercury._handleImminentShutdown(sessionId);
1022
1110
 
1023
1111
  assert.notCalled(connectWithBackoffStub);
1024
1112
  });
1025
1113
 
1026
1114
  it('should set switchover flags when called', () => {
1027
- mercury._handleImminentShutdown();
1115
+ mercury._handleImminentShutdown(sessionId);
1028
1116
 
1029
- assert.isTrue(mercury._shutdownSwitchoverInProgress);
1117
+ // With _connectWithBackoff stubbed, the backoff map entry may not be created here.
1118
+ // Assert that switchover initiation state was set and a shutdown switchover connect was requested.
1030
1119
  assert.isDefined(mercury._shutdownSwitchoverId);
1120
+
1121
+ assert.calledOnce(connectWithBackoffStub);
1122
+ const callArgs = connectWithBackoffStub.firstCall.args;
1123
+ assert.isUndefined(callArgs[0]); // webSocketUrl
1124
+ assert.equal(callArgs[1], sessionId); // sessionId
1125
+ assert.isObject(callArgs[2]); // context
1126
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1127
+ assert.isObject(callArgs[2].attemptOptions);
1128
+ assert.isTrue(callArgs[2].attemptOptions.isShutdownSwitchover);
1031
1129
  });
1032
1130
 
1033
1131
  it('should call _connectWithBackoff with correct parameters', (done) => {
1034
- mercury._handleImminentShutdown();
1132
+ mercury._handleImminentShutdown(sessionId);
1035
1133
 
1036
1134
  process.nextTick(() => {
1037
1135
  assert.calledOnce(connectWithBackoffStub);
1038
1136
  const callArgs = connectWithBackoffStub.firstCall.args;
1039
1137
  assert.isUndefined(callArgs[0]); // webSocketUrl
1040
- assert.isObject(callArgs[1]); // context
1041
- assert.isTrue(callArgs[1].isShutdownSwitchover);
1138
+ assert.equal(callArgs[1], sessionId); // sessionId
1139
+ assert.isObject(callArgs[2]); // context
1140
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1141
+ assert.isObject(callArgs[2].attemptOptions);
1142
+ assert.isTrue(callArgs[2].attemptOptions.isShutdownSwitchover);
1042
1143
  done();
1043
1144
  });
1044
1145
  });
@@ -1047,9 +1148,13 @@ describe('plugin-mercury', () => {
1047
1148
  connectWithBackoffStub.restore();
1048
1149
  sinon.stub(mercury, '_connectWithBackoff').throws(new Error('Connection failed'));
1049
1150
 
1050
- mercury._handleImminentShutdown();
1151
+ mercury._handleImminentShutdown(sessionId);
1051
1152
 
1052
- assert.isFalse(mercury._shutdownSwitchoverInProgress);
1153
+ // When an exception happens synchronously, the placeholder entry
1154
+ // should be removed from the map.
1155
+ const switchoverCall = mercury._shutdownSwitchoverBackoffCalls.get(sessionId);
1156
+ assert.isUndefined(switchoverCall);
1157
+ mercury._connectWithBackoff.restore();
1053
1158
  });
1054
1159
  });
1055
1160
 
@@ -1073,10 +1178,15 @@ describe('plugin-mercury', () => {
1073
1178
  },
1074
1179
  };
1075
1180
 
1076
- const result = mercury._onmessage(shutdownEvent);
1181
+ const result = mercury._onmessage(mercury.defaultSessionId, shutdownEvent);
1077
1182
 
1078
1183
  assert.calledOnce(mercury._handleImminentShutdown);
1079
- assert.calledWith(mercury._emit, 'event:mercury_shutdown_imminent', shutdownEvent.data);
1184
+ assert.calledWith(
1185
+ mercury._emit,
1186
+ mercury.defaultSessionId,
1187
+ 'event:mercury_shutdown_imminent',
1188
+ shutdownEvent.data
1189
+ );
1080
1190
  assert.instanceOf(result, Promise);
1081
1191
  });
1082
1192
 
@@ -1087,7 +1197,7 @@ describe('plugin-mercury', () => {
1087
1197
  },
1088
1198
  };
1089
1199
 
1090
- mercury._onmessage(shutdownEvent);
1200
+ mercury._onmessage(mercury.defaultSessionId, shutdownEvent);
1091
1201
 
1092
1202
  assert.calledOnce(mercury._handleImminentShutdown);
1093
1203
  });
@@ -1102,12 +1212,251 @@ describe('plugin-mercury', () => {
1102
1212
  },
1103
1213
  };
1104
1214
 
1105
- mercury._onmessage(regularEvent);
1215
+ mercury._onmessage(mercury.defaultSessionId, regularEvent);
1106
1216
 
1107
1217
  assert.notCalled(mercury._handleImminentShutdown);
1108
1218
  });
1109
1219
  });
1110
1220
 
1221
+ describe('#_onmessage() with missing data or eventType', () => {
1222
+ beforeEach(() => {
1223
+ sinon.stub(mercury, '_emit');
1224
+ sinon.stub(mercury, '_setTimeOffset');
1225
+ sinon.stub(mercury, '_applyOverrides');
1226
+ });
1227
+
1228
+ afterEach(() => {
1229
+ mercury._emit.restore();
1230
+ mercury._setTimeOffset.restore();
1231
+ mercury._applyOverrides.restore();
1232
+ });
1233
+
1234
+ it('should not throw when envelope.data is undefined', () => {
1235
+ const event = {
1236
+ data: {
1237
+ type: 'someType',
1238
+ // no nested data property
1239
+ },
1240
+ };
1241
+
1242
+ const result = mercury._onmessage(mercury.defaultSessionId, event);
1243
+
1244
+ assert.instanceOf(result, Promise);
1245
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event', event.data);
1246
+ });
1247
+
1248
+ it('should not throw when data.eventType is undefined', () => {
1249
+ const event = {
1250
+ data: {
1251
+ type: 'someType',
1252
+ data: {
1253
+ // no eventType property
1254
+ someField: 'value',
1255
+ },
1256
+ },
1257
+ };
1258
+
1259
+ const result = mercury._onmessage(mercury.defaultSessionId, event);
1260
+
1261
+ assert.instanceOf(result, Promise);
1262
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event', event.data);
1263
+ });
1264
+
1265
+ it('should emit generic event for messages without eventType (e.g. subscription responses)', () => {
1266
+ const event = {
1267
+ data: {
1268
+ id: 'msg-123',
1269
+ sequenceNumber: 5,
1270
+ data: {
1271
+ statusCode: 200,
1272
+ },
1273
+ },
1274
+ };
1275
+
1276
+ const result = mercury._onmessage(mercury.defaultSessionId, event);
1277
+
1278
+ assert.instanceOf(result, Promise);
1279
+ assert.calledOnce(mercury._emit);
1280
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event', event.data);
1281
+ });
1282
+
1283
+ it('should still process messages with a valid eventType', async () => {
1284
+ const event = {
1285
+ data: {
1286
+ data: {
1287
+ eventType: 'conversation.activity',
1288
+ },
1289
+ },
1290
+ };
1291
+
1292
+ await mercury._onmessage(mercury.defaultSessionId, event);
1293
+
1294
+ // Normal flow emits namespace-specific events after processing handlers.
1295
+ // The early-return guard only emits 'event', so asserting these proves the normal path was taken.
1296
+ assert.calledWith(
1297
+ mercury._emit,
1298
+ mercury.defaultSessionId,
1299
+ 'event:conversation',
1300
+ event.data
1301
+ );
1302
+ assert.calledWith(
1303
+ mercury._emit,
1304
+ mercury.defaultSessionId,
1305
+ 'event:conversation.activity',
1306
+ event.data
1307
+ );
1308
+ });
1309
+ });
1310
+
1311
+ describe('#_getEventHandlers()', () => {
1312
+ it('should return an empty array when eventType is undefined', () => {
1313
+ const result = mercury._getEventHandlers(undefined);
1314
+
1315
+ assert.deepEqual(result, []);
1316
+ });
1317
+
1318
+ it('should return an empty array when eventType is null', () => {
1319
+ const result = mercury._getEventHandlers(null);
1320
+
1321
+ assert.deepEqual(result, []);
1322
+ });
1323
+
1324
+ it('should return an empty array when eventType is an empty string', () => {
1325
+ const result = mercury._getEventHandlers('');
1326
+
1327
+ assert.deepEqual(result, []);
1328
+ });
1329
+
1330
+ it('should return an empty array when namespace is not registered', () => {
1331
+ const result = mercury._getEventHandlers('unknownNamespace.someEvent');
1332
+
1333
+ assert.deepEqual(result, []);
1334
+ });
1335
+ });
1336
+
1337
+ describe('#_onclose() with code 4000', () => {
1338
+ let mockSocket, anotherSocket;
1339
+
1340
+ beforeEach(() => {
1341
+ mockSocket = {
1342
+ url: 'ws://active-socket.com',
1343
+ removeAllListeners: sinon.stub(),
1344
+ };
1345
+ anotherSocket = {
1346
+ url: 'ws://old-socket.com',
1347
+ removeAllListeners: sinon.stub(),
1348
+ };
1349
+ mercury.socket = mockSocket;
1350
+ mercury.sockets.set(mercury.defaultSessionId, mockSocket);
1351
+ mercury.sessionWebSocketUrls.set(mercury.defaultSessionId, mockSocket.url);
1352
+ mercury.connected = true;
1353
+ sinon.stub(mercury, '_emit');
1354
+ sinon.stub(mercury, '_reconnect');
1355
+ sinon.stub(mercury, 'unset');
1356
+ });
1357
+
1358
+ afterEach(() => {
1359
+ mercury._emit.restore();
1360
+ mercury._reconnect.restore();
1361
+ mercury.unset.restore();
1362
+ });
1363
+
1364
+ it('should not reconnect when active socket is replaced', () => {
1365
+ const closeEvent = {
1366
+ code: 4000,
1367
+ reason: 'Replaced',
1368
+ };
1369
+
1370
+ mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1371
+
1372
+ assert.calledOnceWithExactly(
1373
+ webex.internal.metrics.submitClientMetrics,
1374
+ 'JS_SDK_MERCURY_CLOSE_4000',
1375
+ {
1376
+ fields: {
1377
+ action: 'no_action',
1378
+ close_code: 4000,
1379
+ close_reason: 'Replaced',
1380
+ is_active_socket: true,
1381
+ message_type: 'replaced',
1382
+ session_id: mercury.defaultSessionId,
1383
+ },
1384
+ tags: {
1385
+ action: 'no_action',
1386
+ message_type: 'replaced',
1387
+ },
1388
+ }
1389
+ );
1390
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent);
1391
+ assert.notCalled(mercury._reconnect);
1392
+ });
1393
+
1394
+ it('should reconnect active socket when 4000 has an unknown reason', () => {
1395
+ const closeEvent = {
1396
+ code: 4000,
1397
+ reason: 'Unexpected close',
1398
+ };
1399
+
1400
+ mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1401
+
1402
+ assert.calledOnceWithExactly(
1403
+ webex.internal.metrics.submitClientMetrics,
1404
+ 'JS_SDK_MERCURY_CLOSE_4000',
1405
+ {
1406
+ fields: {
1407
+ action: 'reconnect',
1408
+ close_code: 4000,
1409
+ close_reason: 'Unexpected close',
1410
+ is_active_socket: true,
1411
+ message_type: 'other',
1412
+ session_id: mercury.defaultSessionId,
1413
+ },
1414
+ tags: {
1415
+ action: 'reconnect',
1416
+ message_type: 'other',
1417
+ },
1418
+ }
1419
+ );
1420
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.transient', closeEvent);
1421
+ assert.calledWith(
1422
+ mercury._reconnect,
1423
+ mockSocket.url,
1424
+ mercury.defaultSessionId
1425
+ );
1426
+ });
1427
+
1428
+ it('should not reconnect a non-active socket with an unknown 4000 reason', () => {
1429
+ const closeEvent = {
1430
+ code: 4000,
1431
+ reason: 'Unexpected close',
1432
+ };
1433
+
1434
+ mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1435
+
1436
+ assert.calledOnceWithExactly(
1437
+ webex.internal.metrics.submitClientMetrics,
1438
+ 'JS_SDK_MERCURY_CLOSE_4000',
1439
+ {
1440
+ fields: {
1441
+ action: 'ignore_non_active',
1442
+ close_code: 4000,
1443
+ close_reason: 'Unexpected close',
1444
+ is_active_socket: false,
1445
+ message_type: 'other',
1446
+ session_id: mercury.defaultSessionId,
1447
+ },
1448
+ tags: {
1449
+ action: 'ignore_non_active',
1450
+ message_type: 'other',
1451
+ },
1452
+ }
1453
+ );
1454
+ assert.notCalled(mercury._emit);
1455
+ assert.notCalled(mercury._reconnect);
1456
+ assert.isTrue(mercury.connected);
1457
+ });
1458
+ });
1459
+
1111
1460
  describe('#_onclose() with code 4001 (shutdown replacement)', () => {
1112
1461
  let mockSocket, anotherSocket;
1113
1462
 
@@ -1121,6 +1470,7 @@ describe('plugin-mercury', () => {
1121
1470
  removeAllListeners: sinon.stub(),
1122
1471
  };
1123
1472
  mercury.socket = mockSocket;
1473
+ mercury.sockets.set(mercury.defaultSessionId, mockSocket);
1124
1474
  mercury.connected = true;
1125
1475
  sinon.stub(mercury, '_emit');
1126
1476
  sinon.stub(mercury, '_reconnect');
@@ -1139,9 +1489,14 @@ describe('plugin-mercury', () => {
1139
1489
  reason: 'replaced during shutdown',
1140
1490
  };
1141
1491
 
1142
- mercury._onclose(closeEvent, mockSocket);
1492
+ mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1143
1493
 
1144
- assert.calledWith(mercury._emit, 'offline.permanent', closeEvent);
1494
+ assert.calledWith(
1495
+ mercury._emit,
1496
+ mercury.defaultSessionId,
1497
+ 'offline.permanent',
1498
+ closeEvent
1499
+ );
1145
1500
  assert.notCalled(mercury._reconnect); // No reconnect for 4001 on active socket
1146
1501
  assert.isFalse(mercury.connected);
1147
1502
  });
@@ -1152,9 +1507,14 @@ describe('plugin-mercury', () => {
1152
1507
  reason: 'replaced during shutdown',
1153
1508
  };
1154
1509
 
1155
- mercury._onclose(closeEvent, anotherSocket);
1510
+ mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1156
1511
 
1157
- assert.calledWith(mercury._emit, 'offline.replaced', closeEvent);
1512
+ assert.calledWith(
1513
+ mercury._emit,
1514
+ mercury.defaultSessionId,
1515
+ 'offline.replaced',
1516
+ closeEvent
1517
+ );
1158
1518
  assert.notCalled(mercury._reconnect);
1159
1519
  assert.isTrue(mercury.connected); // Should remain connected
1160
1520
  assert.notCalled(mercury.unset);
@@ -1167,15 +1527,26 @@ describe('plugin-mercury', () => {
1167
1527
  };
1168
1528
 
1169
1529
  // Test non-active socket
1170
- mercury._onclose(closeEvent, anotherSocket);
1171
- assert.calledWith(mercury._emit, 'offline.replaced', closeEvent);
1530
+ mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1531
+ assert.calledWith(
1532
+ mercury._emit,
1533
+ mercury.defaultSessionId,
1534
+ 'offline.replaced',
1535
+ closeEvent
1536
+ );
1172
1537
 
1173
1538
  // Reset the spy call history
1174
1539
  mercury._emit.resetHistory();
1175
1540
 
1176
1541
  // Test active socket
1177
- mercury._onclose(closeEvent, mockSocket);
1178
- assert.calledWith(mercury._emit, 'offline.permanent', closeEvent);
1542
+ mercury.sockets.set(mercury.defaultSessionId, mockSocket);
1543
+ mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1544
+ assert.calledWith(
1545
+ mercury._emit,
1546
+ mercury.defaultSessionId,
1547
+ 'offline.permanent',
1548
+ closeEvent
1549
+ );
1179
1550
  });
1180
1551
 
1181
1552
  it('should handle missing sourceSocket parameter (treats as non-active)', () => {
@@ -1184,10 +1555,15 @@ describe('plugin-mercury', () => {
1184
1555
  reason: 'replaced during shutdown',
1185
1556
  };
1186
1557
 
1187
- mercury._onclose(closeEvent); // No sourceSocket parameter
1558
+ mercury._onclose(mercury.defaultSessionId, closeEvent); // No sourceSocket parameter
1188
1559
 
1189
1560
  // With simplified logic, undefined !== this.socket, so isActiveSocket = false
1190
- assert.calledWith(mercury._emit, 'offline.replaced', closeEvent);
1561
+ assert.calledWith(
1562
+ mercury._emit,
1563
+ mercury.defaultSessionId,
1564
+ 'offline.replaced',
1565
+ closeEvent
1566
+ );
1191
1567
  assert.notCalled(mercury._reconnect);
1192
1568
  });
1193
1569
 
@@ -1198,7 +1574,7 @@ describe('plugin-mercury', () => {
1198
1574
  };
1199
1575
 
1200
1576
  // Close non-active socket (not the active one)
1201
- mercury._onclose(closeEvent, anotherSocket);
1577
+ mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1202
1578
 
1203
1579
  // Verify listeners were removed from the old socket
1204
1580
  // The _onclose method checks if sourceSocket !== this.socket (non-active)
@@ -1213,22 +1589,94 @@ describe('plugin-mercury', () => {
1213
1589
  };
1214
1590
 
1215
1591
  // Close active socket
1216
- mercury._onclose(closeEvent, mockSocket);
1592
+ mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1217
1593
 
1218
1594
  // Verify listeners were removed from active socket
1219
1595
  assert.calledOnce(mockSocket.removeAllListeners);
1220
1596
  });
1221
1597
  });
1222
1598
 
1599
+ describe('#_onclose() reconnect URL derivation', () => {
1600
+ const sessionId = 'mercury-default-session';
1601
+ // The URL resolved and stored in _prepareAndOpenSocket (catalog-valid host).
1602
+ const resolvedUrl = 'wss://mercury-connection.wbx2.com/v1/apps/wx2/registrations';
1603
+ // The URL the native socket was actually opened with, after a lower
1604
+ // layer (e.g. the same-site websocket proxy) rewrote it into a
1605
+ // non-catalog host. Reusing this would fail host-catalog validation.
1606
+ const proxiedUrl = 'wss://web.webex.com/webproxy/mercury-connection.wbx2.com/v1/apps/wx2';
1607
+ let sourceSocket;
1608
+
1609
+ beforeEach(() => {
1610
+ sourceSocket = {
1611
+ url: proxiedUrl,
1612
+ removeAllListeners: sinon.stub(),
1613
+ };
1614
+ mercury.socket = sourceSocket;
1615
+ mercury.sockets.set(sessionId, sourceSocket);
1616
+ mercury.sessionWebSocketUrls.set(sessionId, resolvedUrl);
1617
+ mercury.connected = true;
1618
+ sinon.stub(mercury, '_emit');
1619
+ sinon.stub(mercury, '_reconnect');
1620
+ sinon.stub(mercury, 'unset');
1621
+ });
1622
+
1623
+ afterEach(() => {
1624
+ mercury._emit.restore();
1625
+ mercury._reconnect.restore();
1626
+ mercury.unset.restore();
1627
+ mercury.sockets.clear();
1628
+ mercury.sessionWebSocketUrls.clear();
1629
+ });
1630
+
1631
+ [1001, 1005, 1006, 1011].forEach((code) => {
1632
+ it(`reconnects an active socket using the stored resolved URL (not the proxied socket.url) for code ${code}`, () => {
1633
+ mercury._onclose(sessionId, {code, reason: 'transient'}, sourceSocket);
1634
+
1635
+ assert.calledOnceWithExactly(mercury._reconnect, resolvedUrl, sessionId);
1636
+ assert.neverCalledWith(mercury._reconnect, proxiedUrl, sessionId);
1637
+ });
1638
+ });
1639
+
1640
+ it('reconnects using the stored resolved URL for a normal close (1000) with a reconnect reason', () => {
1641
+ mercury._onclose(sessionId, {code: 1000, reason: 'done (forced)'}, sourceSocket);
1642
+
1643
+ assert.calledOnceWithExactly(mercury._reconnect, resolvedUrl, sessionId);
1644
+ });
1645
+
1646
+ it('does not reconnect when a non-active (old) socket closes', () => {
1647
+ const oldSocket = {
1648
+ url: proxiedUrl,
1649
+ removeAllListeners: sinon.stub(),
1650
+ };
1651
+
1652
+ mercury._onclose(sessionId, {code: 1006, reason: 'transient'}, oldSocket);
1653
+
1654
+ assert.notCalled(mercury._reconnect);
1655
+ });
1656
+ });
1657
+
1658
+ describe('#disconnectAll()', () => {
1659
+ it('clears sessionWebSocketUrls along with the other session state', async () => {
1660
+ mercury.sessionWebSocketUrls.set('mercury-default-session', 'ws://example.com');
1661
+ mercury.sessionWebSocketUrls.set('llm-session', 'ws://example-2.com');
1662
+
1663
+ await mercury.disconnectAll();
1664
+
1665
+ assert.equal(mercury.sessionWebSocketUrls.size, 0);
1666
+ });
1667
+ });
1668
+
1223
1669
  describe('shutdown switchover with retry logic', () => {
1224
1670
  let connectWithBackoffStub;
1671
+ const sessionId = 'mercury-default-session';
1225
1672
 
1226
1673
  beforeEach(() => {
1227
1674
  mercury.connected = true;
1228
- mercury.socket = {
1675
+ mercury.sockets.set(sessionId, {
1229
1676
  url: 'ws://old-socket.com',
1230
1677
  removeAllListeners: sinon.stub(),
1231
- };
1678
+ });
1679
+ mercury.socket = mercury.sockets.get(sessionId);
1232
1680
  connectWithBackoffStub = sinon.stub(mercury, '_connectWithBackoff');
1233
1681
  sinon.stub(mercury, '_emit');
1234
1682
  });
@@ -1236,39 +1684,46 @@ describe('plugin-mercury', () => {
1236
1684
  afterEach(() => {
1237
1685
  connectWithBackoffStub.restore();
1238
1686
  mercury._emit.restore();
1687
+ mercury.sockets.clear();
1239
1688
  });
1240
1689
 
1241
1690
  it('should call _connectWithBackoff with shutdown switchover context', (done) => {
1242
1691
  connectWithBackoffStub.returns(Promise.resolve());
1243
1692
 
1244
- mercury._handleImminentShutdown();
1693
+ mercury._handleImminentShutdown(sessionId);
1245
1694
 
1246
- // Give it a tick for the async call to happen
1247
1695
  process.nextTick(() => {
1248
1696
  assert.calledOnce(connectWithBackoffStub);
1249
1697
  const callArgs = connectWithBackoffStub.firstCall.args;
1250
1698
 
1251
- assert.isUndefined(callArgs[0]); // webSocketUrl is undefined
1252
- assert.isObject(callArgs[1]); // context object
1253
- assert.isTrue(callArgs[1].isShutdownSwitchover);
1254
- assert.isObject(callArgs[1].attemptOptions);
1255
- assert.isTrue(callArgs[1].attemptOptions.isShutdownSwitchover);
1699
+ assert.isUndefined(callArgs[0]); // webSocketUrl
1700
+ assert.equal(callArgs[1], sessionId);
1701
+ assert.isObject(callArgs[2]);
1702
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1703
+ assert.isObject(callArgs[2].attemptOptions);
1704
+ assert.isTrue(callArgs[2].attemptOptions.isShutdownSwitchover);
1256
1705
  done();
1257
1706
  });
1258
1707
  });
1259
1708
 
1260
1709
  it('should set _shutdownSwitchoverInProgress flag during switchover', () => {
1261
- connectWithBackoffStub.returns(new Promise(() => {})); // Never resolves
1710
+ // With the new behavior, "in progress" is represented by the presence
1711
+ // of an entry in _shutdownSwitchoverBackoffCalls.
1712
+ // Since _connectWithBackoff is stubbed in this suite, simulate its side-effect
1713
+ // of seeding the backoff-call map entry.
1714
+ connectWithBackoffStub.callsFake(() => {
1715
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {placeholder: true});
1716
+ return new Promise(() => {}); // Never resolves
1717
+ });
1262
1718
 
1263
- mercury._handleImminentShutdown();
1719
+ mercury._handleImminentShutdown(sessionId);
1264
1720
 
1265
- assert.isTrue(mercury._shutdownSwitchoverInProgress);
1721
+ const switchoverBackoffCall = mercury._shutdownSwitchoverBackoffCalls.get(sessionId);
1722
+ assert.isOk(switchoverBackoffCall);
1266
1723
  });
1267
1724
 
1268
1725
  it('should emit success event when switchover completes', async () => {
1269
- // We need to actually call the onSuccess callback to trigger the event
1270
- connectWithBackoffStub.callsFake((url, context) => {
1271
- // Simulate successful connection by calling onSuccess
1726
+ connectWithBackoffStub.callsFake((url, sid, context) => {
1272
1727
  if (context && context.attemptOptions && context.attemptOptions.onSuccess) {
1273
1728
  const mockSocket = {url: 'ws://new-socket.com'};
1274
1729
  context.attemptOptions.onSuccess(mockSocket, 'ws://new-socket.com');
@@ -1276,14 +1731,15 @@ describe('plugin-mercury', () => {
1276
1731
  return Promise.resolve();
1277
1732
  });
1278
1733
 
1279
- mercury._handleImminentShutdown();
1734
+ mercury._handleImminentShutdown(sessionId);
1280
1735
 
1281
- // Wait for async operations
1282
1736
  await promiseTick(50);
1283
1737
 
1284
1738
  const emitCalls = mercury._emit.getCalls();
1285
1739
  const hasCompleteEvent = emitCalls.some(
1286
- (call) => call.args[0] === 'event:mercury_shutdown_switchover_complete'
1740
+ (call) =>
1741
+ call.args[0] === sessionId &&
1742
+ call.args[1] === 'event:mercury_shutdown_switchover_complete'
1287
1743
  );
1288
1744
 
1289
1745
  assert.isTrue(hasCompleteEvent, 'Should emit switchover complete event');
@@ -1294,16 +1750,16 @@ describe('plugin-mercury', () => {
1294
1750
 
1295
1751
  connectWithBackoffStub.returns(Promise.reject(testError));
1296
1752
 
1297
- mercury._handleImminentShutdown();
1753
+ mercury._handleImminentShutdown(sessionId);
1298
1754
  await promiseTick(50);
1299
1755
 
1300
- // Check if failure event was emitted
1301
1756
  const emitCalls = mercury._emit.getCalls();
1302
1757
  const hasFailureEvent = emitCalls.some(
1303
1758
  (call) =>
1304
- call.args[0] === 'event:mercury_shutdown_switchover_failed' &&
1305
- call.args[1] &&
1306
- call.args[1].reason === testError
1759
+ call.args[0] === sessionId &&
1760
+ call.args[1] === 'event:mercury_shutdown_switchover_failed' &&
1761
+ call.args[2] &&
1762
+ call.args[2].reason === testError
1307
1763
  );
1308
1764
 
1309
1765
  assert.isTrue(hasFailureEvent, 'Should emit switchover failed event');
@@ -1312,10 +1768,9 @@ describe('plugin-mercury', () => {
1312
1768
  it('should allow old socket to be closed by server after switchover failure', async () => {
1313
1769
  connectWithBackoffStub.returns(Promise.reject(new Error('Failed')));
1314
1770
 
1315
- mercury._handleImminentShutdown();
1771
+ mercury._handleImminentShutdown(sessionId);
1316
1772
  await promiseTick(50);
1317
1773
 
1318
- // Old socket should not be closed immediately - server will close it
1319
1774
  assert.equal(mercury.socket.removeAllListeners.callCount, 0);
1320
1775
  });
1321
1776
  });
@@ -1399,6 +1854,16 @@ describe('plugin-mercury', () => {
1399
1854
  assert.equal(result, 'ws://example.com');
1400
1855
  });
1401
1856
 
1857
+ it('stores the resolved (pre-proxy) URL under the sessionId in sessionWebSocketUrls', async () => {
1858
+ const sessionId = 'llm-session';
1859
+
1860
+ await mercury._prepareAndOpenSocket(mockSocket, 'ws://original.com', sessionId);
1861
+
1862
+ // The stored value is the URL resolved by _prepareUrl, not the raw
1863
+ // input, so _onclose can later reconnect from a catalog-valid host.
1864
+ assert.equal(mercury.sessionWebSocketUrls.get(sessionId), 'ws://example.com');
1865
+ });
1866
+
1402
1867
  it('should handle errors during socket open', async () => {
1403
1868
  mockSocket.open.returns(Promise.reject(new Error('Open failed')));
1404
1869
 
@@ -1407,23 +1872,22 @@ describe('plugin-mercury', () => {
1407
1872
  assert.fail('Should have thrown an error');
1408
1873
  } catch (err) {
1409
1874
  assert.equal(err.message, 'Open failed');
1875
+ assert.equal(err.webSocketUrl, 'ws://example.com');
1410
1876
  }
1411
1877
  });
1412
1878
  });
1413
1879
 
1414
1880
  describe('#_attemptConnection() with shutdown switchover', () => {
1415
- let mockSocket, prepareAndOpenSocketStub, callback;
1881
+ let prepareAndOpenSocketStub, callback;
1882
+ const sessionId = 'mercury-default-session';
1416
1883
 
1417
1884
  beforeEach(() => {
1418
- mockSocket = {
1419
- url: 'ws://test.com',
1420
- };
1421
1885
  prepareAndOpenSocketStub = sinon
1422
1886
  .stub(mercury, '_prepareAndOpenSocket')
1423
1887
  .returns(Promise.resolve('ws://new-socket.com'));
1424
1888
  callback = sinon.stub();
1425
- mercury._shutdownSwitchoverBackoffCall = {}; // Mock backoff call
1426
- mercury.socket = mockSocket;
1889
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {abort: sinon.stub()});
1890
+ mercury.socket = {url: 'ws://test.com'};
1427
1891
  mercury.connected = true;
1428
1892
  sinon.stub(mercury, '_emit');
1429
1893
  sinon.stub(mercury, '_attachSocketEventListeners');
@@ -1433,28 +1897,26 @@ describe('plugin-mercury', () => {
1433
1897
  prepareAndOpenSocketStub.restore();
1434
1898
  mercury._emit.restore();
1435
1899
  mercury._attachSocketEventListeners.restore();
1900
+ mercury._shutdownSwitchoverBackoffCalls.clear();
1436
1901
  });
1437
1902
 
1438
1903
  it('should not set socket reference before opening for shutdown switchover', async () => {
1439
1904
  const originalSocket = mercury.socket;
1440
1905
 
1441
- await mercury._attemptConnection('ws://test.com', callback, {
1906
+ await mercury._attemptConnection('ws://test.com', sessionId, callback, {
1442
1907
  isShutdownSwitchover: true,
1443
1908
  onSuccess: (newSocket, url) => {
1444
- // During onSuccess, verify original socket is still set
1445
- // (socket swap happens inside onSuccess callback in _handleImminentShutdown)
1446
1909
  assert.equal(mercury.socket, originalSocket);
1447
1910
  },
1448
1911
  });
1449
1912
 
1450
- // After onSuccess, socket should still be original since we only swap in _handleImminentShutdown
1451
1913
  assert.equal(mercury.socket, originalSocket);
1452
1914
  });
1453
1915
 
1454
1916
  it('should call onSuccess callback with new socket and URL for shutdown', async () => {
1455
1917
  const onSuccessStub = sinon.stub();
1456
1918
 
1457
- await mercury._attemptConnection('ws://test.com', callback, {
1919
+ await mercury._attemptConnection('ws://test.com', sessionId, callback, {
1458
1920
  isShutdownSwitchover: true,
1459
1921
  onSuccess: onSuccessStub,
1460
1922
  });
@@ -1464,20 +1926,18 @@ describe('plugin-mercury', () => {
1464
1926
  });
1465
1927
 
1466
1928
  it('should emit shutdown switchover complete event', async () => {
1467
- const oldSocket = mercury.socket;
1468
-
1469
- await mercury._attemptConnection('ws://test.com', callback, {
1929
+ await mercury._attemptConnection('ws://test.com', sessionId, callback, {
1470
1930
  isShutdownSwitchover: true,
1471
1931
  onSuccess: (newSocket, url) => {
1472
- // Simulate the onSuccess callback behavior
1473
1932
  mercury.socket = newSocket;
1474
1933
  mercury.connected = true;
1475
- mercury._emit('event:mercury_shutdown_switchover_complete', {url});
1934
+ mercury._emit(sessionId, 'event:mercury_shutdown_switchover_complete', {url});
1476
1935
  },
1477
1936
  });
1478
1937
 
1479
1938
  assert.calledWith(
1480
1939
  mercury._emit,
1940
+ sessionId,
1481
1941
  'event:mercury_shutdown_switchover_complete',
1482
1942
  sinon.match.has('url', 'ws://new-socket.com')
1483
1943
  );
@@ -1486,23 +1946,20 @@ describe('plugin-mercury', () => {
1486
1946
  it('should use simpler error handling for shutdown switchover failures', async () => {
1487
1947
  prepareAndOpenSocketStub.returns(Promise.reject(new Error('Connection failed')));
1488
1948
 
1489
- try {
1490
- await mercury._attemptConnection('ws://test.com', callback, {
1949
+ await mercury
1950
+ ._attemptConnection('ws://test.com', sessionId, callback, {
1491
1951
  isShutdownSwitchover: true,
1492
- });
1493
- } catch (err) {
1494
- // Error should be caught and passed to callback
1495
- }
1952
+ })
1953
+ .catch(() => {});
1496
1954
 
1497
- // Should call callback with error for retry
1498
1955
  assert.calledOnce(callback);
1499
1956
  assert.instanceOf(callback.firstCall.args[0], Error);
1500
1957
  });
1501
1958
 
1502
1959
  it('should check _shutdownSwitchoverBackoffCall for shutdown connections', () => {
1503
- mercury._shutdownSwitchoverBackoffCall = undefined;
1960
+ mercury._shutdownSwitchoverBackoffCalls.clear();
1504
1961
 
1505
- const result = mercury._attemptConnection('ws://test.com', callback, {
1962
+ const result = mercury._attemptConnection('ws://test.com', sessionId, callback, {
1506
1963
  isShutdownSwitchover: true,
1507
1964
  });
1508
1965
 
@@ -1514,35 +1971,28 @@ describe('plugin-mercury', () => {
1514
1971
  });
1515
1972
 
1516
1973
  describe('#_connectWithBackoff() with shutdown switchover', () => {
1517
- // Note: These tests verify the parameterization logic without running real backoff timers
1518
- // to avoid test hangs. The backoff mechanism itself is tested in other test suites.
1974
+ const sessionId = 'mercury-default-session';
1519
1975
 
1520
1976
  it('should use shutdown-specific parameters when called', () => {
1521
- // Stub _connectWithBackoff to prevent real execution
1522
1977
  const connectWithBackoffStub = sinon
1523
1978
  .stub(mercury, '_connectWithBackoff')
1524
1979
  .returns(Promise.resolve());
1525
1980
 
1526
- mercury._handleImminentShutdown();
1981
+ mercury._handleImminentShutdown(sessionId);
1527
1982
 
1528
- // Verify it was called with shutdown context
1529
1983
  assert.calledOnce(connectWithBackoffStub);
1530
1984
  const callArgs = connectWithBackoffStub.firstCall.args;
1531
- assert.isObject(callArgs[1]); // context
1532
- assert.isTrue(callArgs[1].isShutdownSwitchover);
1985
+ assert.equal(callArgs[1], sessionId);
1986
+ assert.isObject(callArgs[2]);
1987
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1533
1988
 
1534
1989
  connectWithBackoffStub.restore();
1535
1990
  });
1536
1991
 
1537
1992
  it('should pass shutdown switchover options to _attemptConnection', () => {
1538
- // Stub _attemptConnection to verify it receives correct options
1539
1993
  const attemptStub = sinon.stub(mercury, '_attemptConnection');
1540
- attemptStub.callsFake((url, callback) => {
1541
- // Immediately succeed
1542
- callback();
1543
- });
1994
+ attemptStub.callsFake((url, sid, cb) => cb());
1544
1995
 
1545
- // Call _connectWithBackoff with shutdown context
1546
1996
  const context = {
1547
1997
  isShutdownSwitchover: true,
1548
1998
  attemptOptions: {
@@ -1551,34 +2001,30 @@ describe('plugin-mercury', () => {
1551
2001
  },
1552
2002
  };
1553
2003
 
1554
- // Start the backoff
1555
- const promise = mercury._connectWithBackoff(undefined, context);
2004
+ const promise = mercury._connectWithBackoff(undefined, sessionId, context);
1556
2005
 
1557
- // Check that _attemptConnection was called with shutdown options
1558
2006
  return promise.then(() => {
1559
2007
  assert.calledOnce(attemptStub);
1560
2008
  const callArgs = attemptStub.firstCall.args;
1561
- assert.isObject(callArgs[2]); // options parameter
1562
- assert.isTrue(callArgs[2].isShutdownSwitchover);
1563
-
2009
+ assert.equal(callArgs[1], sessionId);
2010
+ assert.isObject(callArgs[3]);
2011
+ assert.isTrue(callArgs[3].isShutdownSwitchover);
1564
2012
  attemptStub.restore();
1565
2013
  });
1566
2014
  });
1567
2015
 
1568
2016
  it('should set and clear state flags appropriately', () => {
1569
- // Stub to prevent actual connection
1570
- sinon.stub(mercury, '_attemptConnection').callsFake((url, callback) => callback());
2017
+ sinon.stub(mercury, '_attemptConnection').callsFake((url, sid, cb) => cb());
1571
2018
 
1572
- mercury._shutdownSwitchoverInProgress = true;
2019
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {placeholder: true});
1573
2020
 
1574
- const promise = mercury._connectWithBackoff(undefined, {
2021
+ const promise = mercury._connectWithBackoff(undefined, sessionId, {
1575
2022
  isShutdownSwitchover: true,
1576
2023
  attemptOptions: {isShutdownSwitchover: true, onSuccess: () => {}},
1577
2024
  });
1578
2025
 
1579
2026
  return promise.then(() => {
1580
- // Should be cleared after completion
1581
- assert.isFalse(mercury._shutdownSwitchoverInProgress);
2027
+ assert.isUndefined(mercury._shutdownSwitchoverBackoffCalls.get(sessionId));
1582
2028
  mercury._attemptConnection.restore();
1583
2029
  });
1584
2030
  });
@@ -1586,32 +2032,30 @@ describe('plugin-mercury', () => {
1586
2032
 
1587
2033
  describe('#disconnect() with shutdown switchover in progress', () => {
1588
2034
  let abortStub;
2035
+ const sessionId = 'mercury-default-session';
1589
2036
 
1590
2037
  beforeEach(() => {
1591
- mercury.socket = {
2038
+ mercury.sockets.clear();
2039
+ mercury.sockets.set(sessionId, {
1592
2040
  close: sinon.stub().returns(Promise.resolve()),
1593
2041
  removeAllListeners: sinon.stub(),
1594
- };
2042
+ });
1595
2043
  abortStub = sinon.stub();
1596
- mercury._shutdownSwitchoverBackoffCall = {
1597
- abort: abortStub,
1598
- };
2044
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {abort: abortStub});
1599
2045
  });
1600
2046
 
1601
2047
  it('should abort shutdown switchover backoff call on disconnect', async () => {
1602
- await mercury.disconnect();
2048
+ await mercury.disconnect(undefined, sessionId);
1603
2049
 
1604
2050
  assert.calledOnce(abortStub);
1605
2051
  });
1606
2052
 
1607
2053
  it('should handle disconnect when no switchover is in progress', async () => {
1608
- mercury._shutdownSwitchoverBackoffCall = undefined;
2054
+ mercury._shutdownSwitchoverBackoffCalls.clear();
1609
2055
 
1610
- // Should not throw
1611
- await mercury.disconnect();
2056
+ await mercury.disconnect(undefined, sessionId);
1612
2057
 
1613
- // Should still close the socket
1614
- assert.calledOnce(mercury.socket.close);
2058
+ assert.calledOnce(mercury.sockets.get(sessionId).close);
1615
2059
  });
1616
2060
  });
1617
2061
  });