@webex/internal-plugin-mercury 3.11.0 → 3.12.0-llmrefactor.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.
@@ -102,7 +102,48 @@ describe('plugin-mercury', () => {
102
102
  mercury = webex.internal.mercury;
103
103
  });
104
104
 
105
- afterEach(() => {
105
+ afterEach(async () => {
106
+ // Clean up Mercury connections and internal state synchronously
107
+ // to avoid issues with fake timers
108
+ if (mercury) {
109
+ // Abort any pending backoff calls first
110
+ if (mercury.backoffCall) {
111
+ mercury.backoffCall.abort();
112
+ mercury.backoffCall = undefined;
113
+ }
114
+ if (mercury._shutdownSwitchoverBackoffCall) {
115
+ mercury._shutdownSwitchoverBackoffCall.abort();
116
+ mercury._shutdownSwitchoverBackoffCall = undefined;
117
+ }
118
+
119
+ // Close socket directly without waiting for offline event
120
+ if (mercury.socket) {
121
+ if (typeof mercury.socket.removeAllListeners === 'function') {
122
+ mercury.socket.removeAllListeners();
123
+ }
124
+ try {
125
+ mercury.socket.close();
126
+ } catch (e) {
127
+ // Ignore cleanup errors
128
+ }
129
+ mercury.socket = undefined;
130
+ }
131
+
132
+ // Clear any remaining connection promise
133
+ mercury._connectPromise = null;
134
+ mercury.connected = false;
135
+ mercury.connecting = false;
136
+ }
137
+
138
+ // Ensure mock socket is properly closed
139
+ if (mockWebSocket && typeof mockWebSocket.close === 'function') {
140
+ try {
141
+ mockWebSocket.close();
142
+ } catch (e) {
143
+ // Ignore cleanup errors
144
+ }
145
+ }
146
+
106
147
  if (socketOpenStub) {
107
148
  socketOpenStub.restore();
108
149
  }
@@ -110,6 +151,9 @@ describe('plugin-mercury', () => {
110
151
  if (Socket.getWebSocketConstructor.restore) {
111
152
  Socket.getWebSocketConstructor.restore();
112
153
  }
154
+
155
+ // Small delay to ensure all async operations complete
156
+ await new Promise((resolve) => setTimeout(resolve, 10));
113
157
  });
114
158
 
115
159
  describe('#listen()', () => {
@@ -268,18 +312,20 @@ describe('plugin-mercury', () => {
268
312
  .then(() => {
269
313
  assert.calledOnce(Socket.prototype.open);
270
314
 
271
- return promiseTick(5);
315
+ // Wait for error handling from 1st failure to schedule 2nd timer
316
+ return promiseTick(10);
272
317
  })
273
318
  .then(() => {
274
319
  clock.tick(mercury.config.backoffTimeReset);
275
320
 
276
- return promiseTick(5);
321
+ // Wait for 2nd socket.open call AND error handling to schedule 3rd timer
322
+ return promiseTick(10);
277
323
  })
278
324
  .then(() => {
279
325
  assert.calledTwice(Socket.prototype.open);
280
326
  clock.tick(2 * mercury.config.backoffTimeReset);
281
327
 
282
- return promiseTick(5);
328
+ return promiseTick(10);
283
329
  })
284
330
  .then(() => {
285
331
  assert.calledThrice(Socket.prototype.open);
@@ -354,20 +400,20 @@ describe('plugin-mercury', () => {
354
400
  .then(() => {
355
401
  assert.calledOnce(Socket.prototype.open);
356
402
 
357
- // I'm not sure why, but it's important the clock doesn't advance
358
- // until a tick happens
359
- return promiseTick(5);
403
+ // Wait for error handling from 1st failure to schedule 2nd timer
404
+ return promiseTick(10);
360
405
  })
361
406
  .then(() => {
362
407
  clock.tick(mercury.config.backoffTimeReset);
363
408
 
364
- return promiseTick(5);
409
+ // Wait for 2nd socket.open call AND error handling to schedule 3rd timer
410
+ return promiseTick(10);
365
411
  })
366
412
  .then(() => {
367
413
  assert.calledTwice(Socket.prototype.open);
368
414
  clock.tick(2 * mercury.config.backoffTimeReset);
369
415
 
370
- return promiseTick(5);
416
+ return promiseTick(10);
371
417
  })
372
418
  .then(() => {
373
419
  assert.calledThrice(Socket.prototype.open);
@@ -467,15 +513,49 @@ describe('plugin-mercury', () => {
467
513
  // });
468
514
 
469
515
  describe('when web-high-availability feature is enabled', () => {
470
- it('marks current socket url as failed and get new one on Connection Error', () => {
516
+ it('marks the URL as failed when socket.open() fails with a ConnectionError', () => {
517
+ // When socket.open() fails, _prepareAndOpenSocket attaches webSocketUrl to the error.
518
+ // The error handler then uses wsUrl = newWSUrl || reason.webSocketUrl, so markFailedUrl
519
+ // is called with the URL obtained before the socket open attempt.
471
520
  webex.internal.feature.getFeature.returns(Promise.resolve(true));
472
521
  socketOpenStub.restore();
473
522
  socketOpenStub = sinon.stub(Socket.prototype, 'open').returns(Promise.resolve());
474
523
  socketOpenStub.onCall(0).returns(Promise.reject(new ConnectionError({code: 4001})));
475
524
  const promise = mercury.connect();
476
525
 
477
- return promiseTick(7).then(() => {
526
+ return promiseTick(15).then(() => {
478
527
  assert.calledOnce(webex.internal.services.markFailedUrl);
528
+ assert.calledWith(
529
+ webex.internal.services.markFailedUrl,
530
+ sinon.match(/example-2\.com/)
531
+ );
532
+ clock.tick(1000);
533
+
534
+ return promise;
535
+ });
536
+ });
537
+
538
+ it('does not mark a URL as failed when no wsUrl is available in ConnectionError', () => {
539
+ // If the error has no webSocketUrl and newWSUrl is also undefined,
540
+ // markFailedUrl should not be called.
541
+ webex.internal.feature.getFeature.returns(Promise.resolve(true));
542
+
543
+ const errWithNoUrl = new ConnectionError({code: 4001});
544
+ // Explicitly ensure no webSocketUrl on the error
545
+ delete errWithNoUrl.webSocketUrl;
546
+
547
+ // Stub _prepareAndOpenSocket so it rejects without attaching webSocketUrl
548
+ // (simulating a failure before socket.open is reached, e.g. _prepareUrl failure)
549
+ const prepareAndOpenStub = sinon.stub(mercury, '_prepareAndOpenSocket');
550
+
551
+ prepareAndOpenStub.onCall(0).returns(Promise.reject(errWithNoUrl));
552
+ prepareAndOpenStub.returns(Promise.resolve('ws://example-2.com'));
553
+
554
+ const promise = mercury.connect();
555
+
556
+ return promiseTick(15).then(() => {
557
+ assert.notCalled(webex.internal.services.markFailedUrl);
558
+ prepareAndOpenStub.restore();
479
559
  clock.tick(1000);
480
560
 
481
561
  return promise;
@@ -499,17 +579,22 @@ describe('plugin-mercury', () => {
499
579
 
500
580
  // skipping due to apparent bug with lolex in all browsers but Chrome.
501
581
  skipInBrowser(it)('does not continue attempting to connect', () => {
502
- mercury.connect();
582
+ const promise = mercury.connect();
503
583
 
504
- return promiseTick(2)
505
- .then(() => {
506
- clock.tick(6 * webex.internal.mercury.config.backoffTimeReset);
584
+ // Wait for the connection to be established before proceeding
585
+ mockWebSocket.open();
507
586
 
508
- return promiseTick(2);
509
- })
510
- .then(() => {
511
- assert.calledOnce(Socket.prototype.open);
512
- });
587
+ return promise.then(() =>
588
+ promiseTick(2)
589
+ .then(() => {
590
+ clock.tick(6 * webex.internal.mercury.config.backoffTimeReset);
591
+
592
+ return promiseTick(2);
593
+ })
594
+ .then(() => {
595
+ assert.calledOnce(Socket.prototype.open);
596
+ })
597
+ );
513
598
  });
514
599
  });
515
600
 
@@ -583,44 +668,6 @@ describe('plugin-mercury', () => {
583
668
  });
584
669
  });
585
670
 
586
- describe('#logout()', () => {
587
- it('calls disconnect and logs', () => {
588
- sinon.stub(mercury.logger, 'info');
589
- sinon.stub(mercury, 'disconnect');
590
- mercury.logout();
591
- assert.called(mercury.disconnect);
592
- assert.calledTwice(mercury.logger.info);
593
-
594
- assert.calledWith(mercury.logger.info.getCall(0), 'Mercury: logout() called');
595
- assert.isTrue(
596
- mercury.logger.info
597
- .getCall(1)
598
- .args[0].startsWith('Mercury: debug_mercury_logging stack: ')
599
- );
600
- });
601
-
602
- it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout', () => {
603
- sinon.stub(mercury, 'disconnect');
604
- mercury.config.beforeLogoutOptionsCloseReason = 'done (permanent)';
605
- mercury.logout();
606
- assert.calledWith(mercury.disconnect, {code: 3050, reason: 'done (permanent)'});
607
- });
608
-
609
- 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');
611
- mercury.config.beforeLogoutOptionsCloseReason = 'test';
612
- mercury.logout();
613
- assert.calledWith(mercury.disconnect, {code: 3050, reason: 'test'});
614
- });
615
-
616
- 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');
618
- mercury.config.beforeLogoutOptionsCloseReason = 'done (forced)';
619
- mercury.logout();
620
- assert.calledWith(mercury.disconnect, undefined);
621
- });
622
- });
623
-
624
671
  describe('#disconnect()', () => {
625
672
  it('disconnects the WebSocket', () =>
626
673
  mercury
@@ -723,12 +770,15 @@ describe('plugin-mercury', () => {
723
770
  return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
724
771
  // By this time backoffCall and mercury socket should be defined by the
725
772
  // 'connect' call
726
- assert.isDefined(mercury.backoffCall, 'Mercury backoffCall is not defined');
773
+ assert.isDefined(
774
+ mercury.backoffCall,
775
+ 'Mercury backoffCall is not defined'
776
+ );
727
777
  assert.isDefined(mercury.socket, 'Mercury socket is not defined');
728
778
  // Calling disconnect will abort the backoffCall, close the socket, and
729
779
  // reject the connect
730
780
  mercury.disconnect();
731
- assert.isUndefined(mercury.backoffCall, 'Mercury backoffCall is still defined');
781
+ assert.notExists(mercury.backoffCall, 'Mercury backoffCall is still defined');
732
782
  // The socket will never be unset (which seems bad)
733
783
  assert.isDefined(mercury.socket, 'Mercury socket is not defined');
734
784
 
@@ -747,7 +797,8 @@ describe('plugin-mercury', () => {
747
797
  let reason;
748
798
 
749
799
  mercury.backoffCall = undefined;
750
- mercury._attemptConnection('ws://example.com', (_reason) => {
800
+
801
+ const promise = mercury._attemptConnection('ws://example.com', (_reason) => {
751
802
  reason = _reason;
752
803
  });
753
804
 
@@ -756,6 +807,9 @@ describe('plugin-mercury', () => {
756
807
  reason.message,
757
808
  'Mercury: prevent socket open when backoffCall no longer defined'
758
809
  );
810
+
811
+ // Ensure the promise was actually rejected (short-circuited)
812
+ return assert.isRejected(promise);
759
813
  });
760
814
  });
761
815
 
@@ -1016,7 +1070,8 @@ describe('plugin-mercury', () => {
1016
1070
  });
1017
1071
 
1018
1072
  it('should be idempotent - no-op if already in progress', () => {
1019
- mercury._shutdownSwitchoverInProgress = true;
1073
+ // Simulate an existing switchover in progress
1074
+ mercury._shutdownSwitchoverBackoffCall = {abort: sinon.stub()};
1020
1075
 
1021
1076
  mercury._handleImminentShutdown();
1022
1077
 
@@ -1026,8 +1081,15 @@ describe('plugin-mercury', () => {
1026
1081
  it('should set switchover flags when called', () => {
1027
1082
  mercury._handleImminentShutdown();
1028
1083
 
1029
- assert.isTrue(mercury._shutdownSwitchoverInProgress);
1030
1084
  assert.isDefined(mercury._shutdownSwitchoverId);
1085
+
1086
+ assert.calledOnce(connectWithBackoffStub);
1087
+ const callArgs = connectWithBackoffStub.firstCall.args;
1088
+ assert.isUndefined(callArgs[0]); // webSocketUrl
1089
+ assert.isObject(callArgs[1]); // context
1090
+ assert.isTrue(callArgs[1].isShutdownSwitchover);
1091
+ assert.isObject(callArgs[1].attemptOptions);
1092
+ assert.isTrue(callArgs[1].attemptOptions.isShutdownSwitchover);
1031
1093
  });
1032
1094
 
1033
1095
  it('should call _connectWithBackoff with correct parameters', (done) => {
@@ -1039,6 +1101,8 @@ describe('plugin-mercury', () => {
1039
1101
  assert.isUndefined(callArgs[0]); // webSocketUrl
1040
1102
  assert.isObject(callArgs[1]); // context
1041
1103
  assert.isTrue(callArgs[1].isShutdownSwitchover);
1104
+ assert.isObject(callArgs[1].attemptOptions);
1105
+ assert.isTrue(callArgs[1].attemptOptions.isShutdownSwitchover);
1042
1106
  done();
1043
1107
  });
1044
1108
  });
@@ -1049,7 +1113,9 @@ describe('plugin-mercury', () => {
1049
1113
 
1050
1114
  mercury._handleImminentShutdown();
1051
1115
 
1052
- assert.isFalse(mercury._shutdownSwitchoverInProgress);
1116
+ // When an exception happens synchronously, the backoff call should be cleared
1117
+ assert.notExists(mercury._shutdownSwitchoverBackoffCall);
1118
+ mercury._connectWithBackoff.restore();
1053
1119
  });
1054
1120
  });
1055
1121
 
@@ -1108,6 +1174,117 @@ describe('plugin-mercury', () => {
1108
1174
  });
1109
1175
  });
1110
1176
 
1177
+ describe('#_onmessage() with missing data or eventType', () => {
1178
+ beforeEach(() => {
1179
+ sinon.stub(mercury, '_emit');
1180
+ sinon.stub(mercury, '_setTimeOffset');
1181
+ sinon.stub(mercury, '_applyOverrides');
1182
+ });
1183
+
1184
+ afterEach(() => {
1185
+ mercury._emit.restore();
1186
+ mercury._setTimeOffset.restore();
1187
+ mercury._applyOverrides.restore();
1188
+ });
1189
+
1190
+ it('should emit generic event and return early when envelope.data (nested) is undefined', async () => {
1191
+ const event = {
1192
+ data: {
1193
+ type: 'someType',
1194
+ // no nested data property
1195
+ },
1196
+ };
1197
+
1198
+ const result = mercury._onmessage(event);
1199
+
1200
+ assert.instanceOf(result, Promise);
1201
+ await result;
1202
+ assert.calledOnceWithExactly(mercury._emit, 'event', event.data);
1203
+ assert.notCalled(mercury._applyOverrides);
1204
+ });
1205
+
1206
+ it('should emit generic event and return early when data.eventType is undefined', async () => {
1207
+ const event = {
1208
+ data: {
1209
+ type: 'someType',
1210
+ data: {
1211
+ // no eventType property
1212
+ someField: 'value',
1213
+ },
1214
+ },
1215
+ };
1216
+
1217
+ const result = mercury._onmessage(event);
1218
+
1219
+ assert.instanceOf(result, Promise);
1220
+ await result;
1221
+ assert.calledOnceWithExactly(mercury._emit, 'event', event.data);
1222
+ assert.notCalled(mercury._applyOverrides);
1223
+ });
1224
+
1225
+ it('should emit generic event for messages without eventType (e.g. subscription responses)', async () => {
1226
+ const event = {
1227
+ data: {
1228
+ id: 'msg-123',
1229
+ sequenceNumber: 5,
1230
+ data: {
1231
+ statusCode: 200,
1232
+ },
1233
+ },
1234
+ };
1235
+
1236
+ const result = mercury._onmessage(event);
1237
+
1238
+ assert.instanceOf(result, Promise);
1239
+ await result;
1240
+ assert.calledOnceWithExactly(mercury._emit, 'event', event.data);
1241
+ assert.notCalled(mercury._applyOverrides);
1242
+ });
1243
+
1244
+ it('should still process messages with a valid eventType', async () => {
1245
+ const event = {
1246
+ data: {
1247
+ data: {
1248
+ eventType: 'conversation.activity',
1249
+ },
1250
+ },
1251
+ };
1252
+
1253
+ await mercury._onmessage(event);
1254
+
1255
+ // Normal flow emits namespace-specific events after processing handlers.
1256
+ // The early-return guard only emits 'event', so asserting these proves the normal path was taken.
1257
+ assert.calledWith(mercury._emit, 'event:conversation', event.data);
1258
+ assert.calledWith(mercury._emit, 'event:conversation.activity', event.data);
1259
+ });
1260
+ });
1261
+
1262
+ describe('#_getEventHandlers()', () => {
1263
+ it('should return an empty array when eventType is undefined', () => {
1264
+ const result = mercury._getEventHandlers(undefined);
1265
+
1266
+ assert.deepEqual(result, []);
1267
+ });
1268
+
1269
+ it('should return an empty array when eventType is null', () => {
1270
+ const result = mercury._getEventHandlers(null);
1271
+
1272
+ assert.deepEqual(result, []);
1273
+ });
1274
+
1275
+ it('should return an empty array when eventType is an empty string', () => {
1276
+ const result = mercury._getEventHandlers('');
1277
+
1278
+ assert.deepEqual(result, []);
1279
+ });
1280
+
1281
+ it('should return an empty array when namespace is not registered', () => {
1282
+ const result = mercury._getEventHandlers('unknownNamespace.someEvent');
1283
+
1284
+ assert.deepEqual(result, []);
1285
+ });
1286
+ });
1287
+
1111
1288
  describe('#_onclose() with code 4001 (shutdown replacement)', () => {
1112
1289
  let mockSocket, anotherSocket;
1113
1290
 
@@ -1201,8 +1378,6 @@ describe('plugin-mercury', () => {
1201
1378
  mercury._onclose(closeEvent, anotherSocket);
1202
1379
 
1203
1380
  // Verify listeners were removed from the old socket
1204
- // The _onclose method checks if sourceSocket !== this.socket (non-active)
1205
- // and then calls removeAllListeners in the else branch
1206
1381
  assert.calledOnce(anotherSocket.removeAllListeners);
1207
1382
  });
1208
1383
 
@@ -1220,6 +1395,69 @@ describe('plugin-mercury', () => {
1220
1395
  });
1221
1396
  });
1222
1397
 
1398
+ describe('#_onclose() reconnect URL derivation', () => {
1399
+ // The URL resolved and stored in _prepareAndOpenSocket (catalog-valid host).
1400
+ const resolvedUrl = 'wss://mercury-connection.wbx2.com/v1/apps/wx2/registrations';
1401
+ // The URL the native socket was actually opened with, after a lower
1402
+ // layer (e.g. the same-site websocket proxy) rewrote it into a
1403
+ // non-catalog host. Reusing this would fail host-catalog validation.
1404
+ const proxiedUrl = 'wss://web.webex.com/webproxy/mercury-connection.wbx2.com/v1/apps/wx2';
1405
+ let sourceSocket;
1406
+
1407
+ beforeEach(() => {
1408
+ sourceSocket = {
1409
+ url: proxiedUrl,
1410
+ removeAllListeners: sinon.stub(),
1411
+ };
1412
+ mercury.socket = sourceSocket;
1413
+ mercury.resolvedWebSocketUrl = resolvedUrl;
1414
+ mercury.connected = true;
1415
+ sinon.stub(mercury, '_emit');
1416
+ sinon.stub(mercury, '_reconnect');
1417
+ sinon.stub(mercury, 'unset');
1418
+ });
1419
+
1420
+ afterEach(() => {
1421
+ mercury._emit.restore();
1422
+ mercury._reconnect.restore();
1423
+ mercury.unset.restore();
1424
+ });
1425
+
1426
+ [1001, 1005, 1006, 1011].forEach((code) => {
1427
+ it(`reconnects an active socket using the stored resolved URL (not the proxied socket.url) for code ${code}`, () => {
1428
+ mercury._onclose({code, reason: 'transient'}, sourceSocket);
1429
+
1430
+ assert.calledOnceWithExactly(mercury._reconnect, resolvedUrl);
1431
+ assert.neverCalledWith(mercury._reconnect, proxiedUrl);
1432
+ });
1433
+ });
1434
+
1435
+ it('reconnects using the stored resolved URL for a normal close (1000) with a reconnect reason', () => {
1436
+ mercury._onclose({code: 1000, reason: 'done (forced)'}, sourceSocket);
1437
+
1438
+ assert.calledOnceWithExactly(mercury._reconnect, resolvedUrl);
1439
+ });
1440
+
1441
+ it('does not reconnect when a non-active (old) socket closes', () => {
1442
+ const oldSocket = {
1443
+ url: proxiedUrl,
1444
+ removeAllListeners: sinon.stub(),
1445
+ };
1446
+
1447
+ mercury._onclose({code: 1006, reason: 'transient'}, oldSocket);
1448
+
1449
+ assert.notCalled(mercury._reconnect);
1450
+ });
1451
+
1452
+ it('falls back to socket.url when resolvedWebSocketUrl is not set', () => {
1453
+ mercury.resolvedWebSocketUrl = undefined;
1454
+
1455
+ mercury._onclose({code: 1006, reason: 'transient'}, sourceSocket);
1456
+
1457
+ assert.calledOnceWithExactly(mercury._reconnect, proxiedUrl);
1458
+ });
1459
+ });
1460
+
1223
1461
  describe('shutdown switchover with retry logic', () => {
1224
1462
  let connectWithBackoffStub;
1225
1463
 
@@ -1243,13 +1481,12 @@ describe('plugin-mercury', () => {
1243
1481
 
1244
1482
  mercury._handleImminentShutdown();
1245
1483
 
1246
- // Give it a tick for the async call to happen
1247
1484
  process.nextTick(() => {
1248
1485
  assert.calledOnce(connectWithBackoffStub);
1249
1486
  const callArgs = connectWithBackoffStub.firstCall.args;
1250
1487
 
1251
- assert.isUndefined(callArgs[0]); // webSocketUrl is undefined
1252
- assert.isObject(callArgs[1]); // context object
1488
+ assert.isUndefined(callArgs[0]); // webSocketUrl
1489
+ assert.isObject(callArgs[1]);
1253
1490
  assert.isTrue(callArgs[1].isShutdownSwitchover);
1254
1491
  assert.isObject(callArgs[1].attemptOptions);
1255
1492
  assert.isTrue(callArgs[1].attemptOptions.isShutdownSwitchover);
@@ -1257,18 +1494,21 @@ describe('plugin-mercury', () => {
1257
1494
  });
1258
1495
  });
1259
1496
 
1260
- it('should set _shutdownSwitchoverInProgress flag during switchover', () => {
1261
- connectWithBackoffStub.returns(new Promise(() => {})); // Never resolves
1497
+ it('should set _shutdownSwitchoverBackoffCall during switchover', () => {
1498
+ // Since _connectWithBackoff is stubbed, we can just check that
1499
+ // _handleImminentShutdown sets the flag
1500
+ connectWithBackoffStub.callsFake(() => {
1501
+ return new Promise(() => {}); // Never resolves
1502
+ });
1262
1503
 
1263
1504
  mercury._handleImminentShutdown();
1264
1505
 
1265
- assert.isTrue(mercury._shutdownSwitchoverInProgress);
1506
+ // The switchover id is set when switchover starts
1507
+ assert.isDefined(mercury._shutdownSwitchoverId);
1266
1508
  });
1267
1509
 
1268
1510
  it('should emit success event when switchover completes', async () => {
1269
- // We need to actually call the onSuccess callback to trigger the event
1270
1511
  connectWithBackoffStub.callsFake((url, context) => {
1271
- // Simulate successful connection by calling onSuccess
1272
1512
  if (context && context.attemptOptions && context.attemptOptions.onSuccess) {
1273
1513
  const mockSocket = {url: 'ws://new-socket.com'};
1274
1514
  context.attemptOptions.onSuccess(mockSocket, 'ws://new-socket.com');
@@ -1278,7 +1518,6 @@ describe('plugin-mercury', () => {
1278
1518
 
1279
1519
  mercury._handleImminentShutdown();
1280
1520
 
1281
- // Wait for async operations
1282
1521
  await promiseTick(50);
1283
1522
 
1284
1523
  const emitCalls = mercury._emit.getCalls();
@@ -1292,12 +1531,11 @@ describe('plugin-mercury', () => {
1292
1531
  it('should emit failure event when switchover exhausts retries', async () => {
1293
1532
  const testError = new Error('Connection failed');
1294
1533
 
1295
- connectWithBackoffStub.returns(Promise.reject(testError));
1534
+ connectWithBackoffStub.callsFake(() => Promise.reject(testError));
1296
1535
 
1297
1536
  mercury._handleImminentShutdown();
1298
1537
  await promiseTick(50);
1299
1538
 
1300
- // Check if failure event was emitted
1301
1539
  const emitCalls = mercury._emit.getCalls();
1302
1540
  const hasFailureEvent = emitCalls.some(
1303
1541
  (call) =>
@@ -1310,12 +1548,11 @@ describe('plugin-mercury', () => {
1310
1548
  });
1311
1549
 
1312
1550
  it('should allow old socket to be closed by server after switchover failure', async () => {
1313
- connectWithBackoffStub.returns(Promise.reject(new Error('Failed')));
1551
+ connectWithBackoffStub.callsFake(() => Promise.reject(new Error('Failed')));
1314
1552
 
1315
1553
  mercury._handleImminentShutdown();
1316
1554
  await promiseTick(50);
1317
1555
 
1318
- // Old socket should not be closed immediately - server will close it
1319
1556
  assert.equal(mercury.socket.removeAllListeners.callCount, 0);
1320
1557
  });
1321
1558
  });
@@ -1399,6 +1636,23 @@ describe('plugin-mercury', () => {
1399
1636
  assert.equal(result, 'ws://example.com');
1400
1637
  });
1401
1638
 
1639
+ it('stores the resolved (pre-proxy) URL in resolvedWebSocketUrl', async () => {
1640
+ await mercury._prepareAndOpenSocket(mockSocket, 'ws://original.com', false);
1641
+
1642
+ // The stored value is the URL resolved by _prepareUrl, not the raw
1643
+ // input, so _onclose can later reconnect from a catalog-valid host.
1644
+ assert.equal(mercury.resolvedWebSocketUrl, 'ws://example.com');
1645
+ });
1646
+
1647
+ it('does not store resolvedWebSocketUrl for shutdown switchover connections', async () => {
1648
+ mercury.resolvedWebSocketUrl = 'ws://existing.com';
1649
+
1650
+ await mercury._prepareAndOpenSocket(mockSocket, 'ws://original.com', true);
1651
+
1652
+ // Shutdown switchover should not overwrite the stored URL
1653
+ assert.equal(mercury.resolvedWebSocketUrl, 'ws://existing.com');
1654
+ });
1655
+
1402
1656
  it('should handle errors during socket open', async () => {
1403
1657
  mockSocket.open.returns(Promise.reject(new Error('Open failed')));
1404
1658
 
@@ -1407,23 +1661,21 @@ describe('plugin-mercury', () => {
1407
1661
  assert.fail('Should have thrown an error');
1408
1662
  } catch (err) {
1409
1663
  assert.equal(err.message, 'Open failed');
1664
+ assert.equal(err.webSocketUrl, 'ws://example.com');
1410
1665
  }
1411
1666
  });
1412
1667
  });
1413
1668
 
1414
1669
  describe('#_attemptConnection() with shutdown switchover', () => {
1415
- let mockSocket, prepareAndOpenSocketStub, callback;
1670
+ let prepareAndOpenSocketStub, callback;
1416
1671
 
1417
1672
  beforeEach(() => {
1418
- mockSocket = {
1419
- url: 'ws://test.com',
1420
- };
1421
1673
  prepareAndOpenSocketStub = sinon
1422
1674
  .stub(mercury, '_prepareAndOpenSocket')
1423
1675
  .returns(Promise.resolve('ws://new-socket.com'));
1424
1676
  callback = sinon.stub();
1425
- mercury._shutdownSwitchoverBackoffCall = {}; // Mock backoff call
1426
- mercury.socket = mockSocket;
1677
+ mercury._shutdownSwitchoverBackoffCall = {abort: sinon.stub()};
1678
+ mercury.socket = {url: 'ws://test.com'};
1427
1679
  mercury.connected = true;
1428
1680
  sinon.stub(mercury, '_emit');
1429
1681
  sinon.stub(mercury, '_attachSocketEventListeners');
@@ -1433,6 +1685,7 @@ describe('plugin-mercury', () => {
1433
1685
  prepareAndOpenSocketStub.restore();
1434
1686
  mercury._emit.restore();
1435
1687
  mercury._attachSocketEventListeners.restore();
1688
+ mercury._shutdownSwitchoverBackoffCall = undefined;
1436
1689
  });
1437
1690
 
1438
1691
  it('should not set socket reference before opening for shutdown switchover', async () => {
@@ -1441,13 +1694,10 @@ describe('plugin-mercury', () => {
1441
1694
  await mercury._attemptConnection('ws://test.com', callback, {
1442
1695
  isShutdownSwitchover: true,
1443
1696
  onSuccess: (newSocket, url) => {
1444
- // During onSuccess, verify original socket is still set
1445
- // (socket swap happens inside onSuccess callback in _handleImminentShutdown)
1446
1697
  assert.equal(mercury.socket, originalSocket);
1447
1698
  },
1448
1699
  });
1449
1700
 
1450
- // After onSuccess, socket should still be original since we only swap in _handleImminentShutdown
1451
1701
  assert.equal(mercury.socket, originalSocket);
1452
1702
  });
1453
1703
 
@@ -1464,12 +1714,9 @@ describe('plugin-mercury', () => {
1464
1714
  });
1465
1715
 
1466
1716
  it('should emit shutdown switchover complete event', async () => {
1467
- const oldSocket = mercury.socket;
1468
-
1469
1717
  await mercury._attemptConnection('ws://test.com', callback, {
1470
1718
  isShutdownSwitchover: true,
1471
1719
  onSuccess: (newSocket, url) => {
1472
- // Simulate the onSuccess callback behavior
1473
1720
  mercury.socket = newSocket;
1474
1721
  mercury.connected = true;
1475
1722
  mercury._emit('event:mercury_shutdown_switchover_complete', {url});
@@ -1486,15 +1733,12 @@ describe('plugin-mercury', () => {
1486
1733
  it('should use simpler error handling for shutdown switchover failures', async () => {
1487
1734
  prepareAndOpenSocketStub.returns(Promise.reject(new Error('Connection failed')));
1488
1735
 
1489
- try {
1490
- await mercury._attemptConnection('ws://test.com', callback, {
1736
+ await mercury
1737
+ ._attemptConnection('ws://test.com', callback, {
1491
1738
  isShutdownSwitchover: true,
1492
- });
1493
- } catch (err) {
1494
- // Error should be caught and passed to callback
1495
- }
1739
+ })
1740
+ .catch(() => {});
1496
1741
 
1497
- // Should call callback with error for retry
1498
1742
  assert.calledOnce(callback);
1499
1743
  assert.instanceOf(callback.firstCall.args[0], Error);
1500
1744
  });
@@ -1514,35 +1758,26 @@ describe('plugin-mercury', () => {
1514
1758
  });
1515
1759
 
1516
1760
  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.
1519
-
1520
1761
  it('should use shutdown-specific parameters when called', () => {
1521
- // Stub _connectWithBackoff to prevent real execution
1522
1762
  const connectWithBackoffStub = sinon
1523
1763
  .stub(mercury, '_connectWithBackoff')
1524
1764
  .returns(Promise.resolve());
1525
1765
 
1526
1766
  mercury._handleImminentShutdown();
1527
1767
 
1528
- // Verify it was called with shutdown context
1529
1768
  assert.calledOnce(connectWithBackoffStub);
1530
1769
  const callArgs = connectWithBackoffStub.firstCall.args;
1531
- assert.isObject(callArgs[1]); // context
1770
+ // First arg is the URL, second is the context object
1771
+ assert.isObject(callArgs[1]);
1532
1772
  assert.isTrue(callArgs[1].isShutdownSwitchover);
1533
1773
 
1534
1774
  connectWithBackoffStub.restore();
1535
1775
  });
1536
1776
 
1537
1777
  it('should pass shutdown switchover options to _attemptConnection', () => {
1538
- // Stub _attemptConnection to verify it receives correct options
1539
1778
  const attemptStub = sinon.stub(mercury, '_attemptConnection');
1540
- attemptStub.callsFake((url, callback) => {
1541
- // Immediately succeed
1542
- callback();
1543
- });
1779
+ attemptStub.callsFake((url, cb) => cb());
1544
1780
 
1545
- // Call _connectWithBackoff with shutdown context
1546
1781
  const context = {
1547
1782
  isShutdownSwitchover: true,
1548
1783
  attemptOptions: {
@@ -1551,25 +1786,25 @@ describe('plugin-mercury', () => {
1551
1786
  },
1552
1787
  };
1553
1788
 
1554
- // Start the backoff
1555
1789
  const promise = mercury._connectWithBackoff(undefined, context);
1556
1790
 
1557
- // Check that _attemptConnection was called with shutdown options
1558
1791
  return promise.then(() => {
1559
1792
  assert.calledOnce(attemptStub);
1560
1793
  const callArgs = attemptStub.firstCall.args;
1561
- assert.isObject(callArgs[2]); // options parameter
1794
+ assert.isObject(callArgs[2]);
1562
1795
  assert.isTrue(callArgs[2].isShutdownSwitchover);
1563
-
1564
1796
  attemptStub.restore();
1565
1797
  });
1566
1798
  });
1567
1799
 
1568
- it('should set and clear state flags appropriately', () => {
1569
- // Stub to prevent actual connection
1570
- sinon.stub(mercury, '_attemptConnection').callsFake((url, callback) => callback());
1800
+ it('should set _shutdownSwitchoverBackoffCall during attempt and clear it after completion', () => {
1801
+ let backoffCallDuringAttempt;
1571
1802
 
1572
- mercury._shutdownSwitchoverInProgress = true;
1803
+ sinon.stub(mercury, '_attemptConnection').callsFake((url, cb) => {
1804
+ // Capture the backoff call state during the connection attempt
1805
+ backoffCallDuringAttempt = mercury._shutdownSwitchoverBackoffCall;
1806
+ cb();
1807
+ });
1573
1808
 
1574
1809
  const promise = mercury._connectWithBackoff(undefined, {
1575
1810
  isShutdownSwitchover: true,
@@ -1577,8 +1812,13 @@ describe('plugin-mercury', () => {
1577
1812
  });
1578
1813
 
1579
1814
  return promise.then(() => {
1580
- // Should be cleared after completion
1581
- assert.isFalse(mercury._shutdownSwitchoverInProgress);
1815
+ // Verify backoff call was set during the operation
1816
+ assert.isDefined(backoffCallDuringAttempt, 'backoffCall should be set during attempt');
1817
+ // Verify backoff call is cleared after completion
1818
+ assert.notExists(
1819
+ mercury._shutdownSwitchoverBackoffCall,
1820
+ 'backoffCall should be cleared after completion'
1821
+ );
1582
1822
  mercury._attemptConnection.restore();
1583
1823
  });
1584
1824
  });
@@ -1588,30 +1828,28 @@ describe('plugin-mercury', () => {
1588
1828
  let abortStub;
1589
1829
 
1590
1830
  beforeEach(() => {
1591
- mercury.socket = {
1592
- close: sinon.stub().returns(Promise.resolve()),
1593
- removeAllListeners: sinon.stub(),
1594
- };
1595
1831
  abortStub = sinon.stub();
1596
- mercury._shutdownSwitchoverBackoffCall = {
1597
- abort: abortStub,
1598
- };
1832
+ mercury._shutdownSwitchoverBackoffCall = {abort: abortStub};
1599
1833
  });
1600
1834
 
1601
- it('should abort shutdown switchover backoff call on disconnect', async () => {
1602
- await mercury.disconnect();
1835
+ it('should abort shutdown switchover backoff call on disconnect', () => {
1836
+ // Don't set up socket so disconnect resolves immediately
1837
+ mercury.socket = undefined;
1603
1838
 
1604
- assert.calledOnce(abortStub);
1839
+ return mercury.disconnect().then(() => {
1840
+ assert.calledOnce(abortStub);
1841
+ });
1605
1842
  });
1606
1843
 
1607
- it('should handle disconnect when no switchover is in progress', async () => {
1844
+ it('should handle disconnect when no switchover is in progress', () => {
1608
1845
  mercury._shutdownSwitchoverBackoffCall = undefined;
1846
+ // Don't set up socket so disconnect resolves immediately
1847
+ mercury.socket = undefined;
1609
1848
 
1610
- // Should not throw
1611
- await mercury.disconnect();
1612
-
1613
- // Should still close the socket
1614
- assert.calledOnce(mercury.socket.close);
1849
+ return mercury.disconnect().then(() => {
1850
+ // If we get here, disconnect completed without error
1851
+ assert.notExists(mercury._shutdownSwitchoverBackoffCall);
1852
+ });
1615
1853
  });
1616
1854
  });
1617
1855
  });