@webex/internal-plugin-mercury 3.12.0-next.5 → 3.12.0-next.50

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.
@@ -135,7 +135,7 @@ describe('plugin-mercury', () => {
135
135
  }
136
136
 
137
137
  // Small delay to ensure all async operations complete
138
- await new Promise(resolve => setTimeout(resolve, 10));
138
+ await new Promise((resolve) => setTimeout(resolve, 10));
139
139
  });
140
140
 
141
141
  describe('#listen()', () => {
@@ -294,18 +294,20 @@ describe('plugin-mercury', () => {
294
294
  .then(() => {
295
295
  assert.calledOnce(Socket.prototype.open);
296
296
 
297
- return promiseTick(5);
297
+ // Wait for error handling from 1st failure to schedule 2nd timer
298
+ return promiseTick(10);
298
299
  })
299
300
  .then(() => {
300
301
  clock.tick(mercury.config.backoffTimeReset);
301
302
 
302
- return promiseTick(5);
303
+ // Wait for 2nd socket.open call AND error handling to schedule 3rd timer
304
+ return promiseTick(10);
303
305
  })
304
306
  .then(() => {
305
307
  assert.calledTwice(Socket.prototype.open);
306
308
  clock.tick(2 * mercury.config.backoffTimeReset);
307
309
 
308
- return promiseTick(5);
310
+ return promiseTick(10);
309
311
  })
310
312
  .then(() => {
311
313
  assert.calledThrice(Socket.prototype.open);
@@ -380,20 +382,20 @@ describe('plugin-mercury', () => {
380
382
  .then(() => {
381
383
  assert.calledOnce(Socket.prototype.open);
382
384
 
383
- // I'm not sure why, but it's important the clock doesn't advance
384
- // until a tick happens
385
- return promiseTick(5);
385
+ // Wait for error handling from 1st failure to schedule 2nd timer
386
+ return promiseTick(10);
386
387
  })
387
388
  .then(() => {
388
389
  clock.tick(mercury.config.backoffTimeReset);
389
390
 
390
- return promiseTick(5);
391
+ // Wait for 2nd socket.open call AND error handling to schedule 3rd timer
392
+ return promiseTick(10);
391
393
  })
392
394
  .then(() => {
393
395
  assert.calledTwice(Socket.prototype.open);
394
396
  clock.tick(2 * mercury.config.backoffTimeReset);
395
397
 
396
- return promiseTick(5);
398
+ return promiseTick(10);
397
399
  })
398
400
  .then(() => {
399
401
  assert.calledThrice(Socket.prototype.open);
@@ -493,15 +495,49 @@ describe('plugin-mercury', () => {
493
495
  // });
494
496
 
495
497
  describe('when web-high-availability feature is enabled', () => {
496
- 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.
497
502
  webex.internal.feature.getFeature.returns(Promise.resolve(true));
498
503
  socketOpenStub.restore();
499
504
  socketOpenStub = sinon.stub(Socket.prototype, 'open').returns(Promise.resolve());
500
505
  socketOpenStub.onCall(0).returns(Promise.reject(new ConnectionError({code: 4001})));
501
506
  const promise = mercury.connect();
502
507
 
503
- return promiseTick(7).then(() => {
508
+ return promiseTick(15).then(() => {
504
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();
505
541
  clock.tick(1000);
506
542
 
507
543
  return promise;
@@ -532,13 +568,13 @@ describe('plugin-mercury', () => {
532
568
 
533
569
  return promise.then(() =>
534
570
  promiseTick(2)
535
- .then(() => {
536
- clock.tick(6 * webex.internal.mercury.config.backoffTimeReset);
571
+ .then(() => {
572
+ clock.tick(6 * webex.internal.mercury.config.backoffTimeReset);
537
573
 
538
- return promiseTick(2);
539
- })
540
- .then(() => {
541
- assert.calledOnce(Socket.prototype.open);
574
+ return promiseTick(2);
575
+ })
576
+ .then(() => {
577
+ assert.calledOnce(Socket.prototype.open);
542
578
  })
543
579
  );
544
580
  });
@@ -754,12 +790,18 @@ describe('plugin-mercury', () => {
754
790
  return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
755
791
  // By this time backoffCall and mercury socket should be defined by the
756
792
  // 'connect' call
757
- assert.isDefined(mercury.backoffCalls.get('mercury-default-session'), 'Mercury backoffCall is not defined');
793
+ assert.isDefined(
794
+ mercury.backoffCalls.get('mercury-default-session'),
795
+ 'Mercury backoffCall is not defined'
796
+ );
758
797
  assert.isDefined(mercury.socket, 'Mercury socket is not defined');
759
798
  // Calling disconnect will abort the backoffCall, close the socket, and
760
799
  // reject the connect
761
800
  mercury.disconnect();
762
- assert.isUndefined(mercury.backoffCalls.get('mercury-default-session'), 'Mercury backoffCall is still defined');
801
+ assert.isUndefined(
802
+ mercury.backoffCalls.get('mercury-default-session'),
803
+ 'Mercury backoffCall is still defined'
804
+ );
763
805
  // The socket will never be unset (which seems bad)
764
806
  assert.isDefined(mercury.socket, 'Mercury socket is not defined');
765
807
 
@@ -815,7 +857,10 @@ describe('plugin-mercury', () => {
815
857
  return assert.isRejected(promise).then((error) => {
816
858
  const lastError = mercury.getLastError();
817
859
 
818
- assert.equal(error.message, `Mercury Connection Aborted for ${mercury.defaultSessionId}`);
860
+ assert.equal(
861
+ error.message,
862
+ `Mercury Connection Aborted for ${mercury.defaultSessionId}`
863
+ );
819
864
  assert.isDefined(lastError);
820
865
  assert.equal(lastError, realError);
821
866
  });
@@ -1113,7 +1158,6 @@ describe('plugin-mercury', () => {
1113
1158
  });
1114
1159
  });
1115
1160
 
1116
-
1117
1161
  describe('#_onmessage() with shutdown message', () => {
1118
1162
  beforeEach(() => {
1119
1163
  sinon.stub(mercury, '_handleImminentShutdown');
@@ -1249,8 +1293,18 @@ describe('plugin-mercury', () => {
1249
1293
 
1250
1294
  // Normal flow emits namespace-specific events after processing handlers.
1251
1295
  // The early-return guard only emits 'event', so asserting these proves the normal path was taken.
1252
- assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event:conversation', event.data);
1253
- assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event:conversation.activity', event.data);
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
+ );
1254
1308
  });
1255
1309
  });
1256
1310
 
@@ -1280,6 +1334,129 @@ describe('plugin-mercury', () => {
1280
1334
  });
1281
1335
  });
1282
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
+
1283
1460
  describe('#_onclose() with code 4001 (shutdown replacement)', () => {
1284
1461
  let mockSocket, anotherSocket;
1285
1462
 
@@ -1314,7 +1491,12 @@ describe('plugin-mercury', () => {
1314
1491
 
1315
1492
  mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1316
1493
 
1317
- assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.permanent', closeEvent);
1494
+ assert.calledWith(
1495
+ mercury._emit,
1496
+ mercury.defaultSessionId,
1497
+ 'offline.permanent',
1498
+ closeEvent
1499
+ );
1318
1500
  assert.notCalled(mercury._reconnect); // No reconnect for 4001 on active socket
1319
1501
  assert.isFalse(mercury.connected);
1320
1502
  });
@@ -1327,7 +1509,12 @@ describe('plugin-mercury', () => {
1327
1509
 
1328
1510
  mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1329
1511
 
1330
- assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent);
1512
+ assert.calledWith(
1513
+ mercury._emit,
1514
+ mercury.defaultSessionId,
1515
+ 'offline.replaced',
1516
+ closeEvent
1517
+ );
1331
1518
  assert.notCalled(mercury._reconnect);
1332
1519
  assert.isTrue(mercury.connected); // Should remain connected
1333
1520
  assert.notCalled(mercury.unset);
@@ -1341,7 +1528,12 @@ describe('plugin-mercury', () => {
1341
1528
 
1342
1529
  // Test non-active socket
1343
1530
  mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1344
- assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent);
1531
+ assert.calledWith(
1532
+ mercury._emit,
1533
+ mercury.defaultSessionId,
1534
+ 'offline.replaced',
1535
+ closeEvent
1536
+ );
1345
1537
 
1346
1538
  // Reset the spy call history
1347
1539
  mercury._emit.resetHistory();
@@ -1349,7 +1541,12 @@ describe('plugin-mercury', () => {
1349
1541
  // Test active socket
1350
1542
  mercury.sockets.set(mercury.defaultSessionId, mockSocket);
1351
1543
  mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1352
- assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.permanent', closeEvent);
1544
+ assert.calledWith(
1545
+ mercury._emit,
1546
+ mercury.defaultSessionId,
1547
+ 'offline.permanent',
1548
+ closeEvent
1549
+ );
1353
1550
  });
1354
1551
 
1355
1552
  it('should handle missing sourceSocket parameter (treats as non-active)', () => {
@@ -1361,7 +1558,12 @@ describe('plugin-mercury', () => {
1361
1558
  mercury._onclose(mercury.defaultSessionId, closeEvent); // No sourceSocket parameter
1362
1559
 
1363
1560
  // With simplified logic, undefined !== this.socket, so isActiveSocket = false
1364
- assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent);
1561
+ assert.calledWith(
1562
+ mercury._emit,
1563
+ mercury.defaultSessionId,
1564
+ 'offline.replaced',
1565
+ closeEvent
1566
+ );
1365
1567
  assert.notCalled(mercury._reconnect);
1366
1568
  });
1367
1569
 
@@ -1394,6 +1596,76 @@ describe('plugin-mercury', () => {
1394
1596
  });
1395
1597
  });
1396
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
+
1397
1669
  describe('shutdown switchover with retry logic', () => {
1398
1670
  let connectWithBackoffStub;
1399
1671
  const sessionId = 'mercury-default-session';
@@ -1582,6 +1854,16 @@ describe('plugin-mercury', () => {
1582
1854
  assert.equal(result, 'ws://example.com');
1583
1855
  });
1584
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
+
1585
1867
  it('should handle errors during socket open', async () => {
1586
1868
  mockSocket.open.returns(Promise.reject(new Error('Open failed')));
1587
1869
 
@@ -1590,6 +1872,7 @@ describe('plugin-mercury', () => {
1590
1872
  assert.fail('Should have thrown an error');
1591
1873
  } catch (err) {
1592
1874
  assert.equal(err.message, 'Open failed');
1875
+ assert.equal(err.webSocketUrl, 'ws://example.com');
1593
1876
  }
1594
1877
  });
1595
1878
  });
@@ -1648,11 +1931,7 @@ describe('plugin-mercury', () => {
1648
1931
  onSuccess: (newSocket, url) => {
1649
1932
  mercury.socket = newSocket;
1650
1933
  mercury.connected = true;
1651
- mercury._emit(
1652
- sessionId,
1653
- 'event:mercury_shutdown_switchover_complete',
1654
- {url}
1655
- );
1934
+ mercury._emit(sessionId, 'event:mercury_shutdown_switchover_complete', {url});
1656
1935
  },
1657
1936
  });
1658
1937
 
@@ -1680,12 +1959,9 @@ describe('plugin-mercury', () => {
1680
1959
  it('should check _shutdownSwitchoverBackoffCall for shutdown connections', () => {
1681
1960
  mercury._shutdownSwitchoverBackoffCalls.clear();
1682
1961
 
1683
- const result = mercury._attemptConnection(
1684
- 'ws://test.com',
1685
- sessionId,
1686
- callback,
1687
- {isShutdownSwitchover: true}
1688
- );
1962
+ const result = mercury._attemptConnection('ws://test.com', sessionId, callback, {
1963
+ isShutdownSwitchover: true,
1964
+ });
1689
1965
 
1690
1966
  return result.catch((err) => {
1691
1967
  assert.instanceOf(err, Error);