@webex/plugin-meetings 3.12.0-next.78 → 3.12.0-next.79

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.
@@ -500,7 +500,7 @@ export default class Meeting extends StatelessWebexPlugin {
500
500
  private deferSDPAnswer?;
501
501
  private sdpResponseTimer?;
502
502
  private hasMediaConnectionConnectedAtLeastOnce;
503
- private joinWithMediaRetryInfo?;
503
+ private joinWithMediaRetryInfo;
504
504
  private connectionStateHandler?;
505
505
  private iceCandidateErrors;
506
506
  private iceCandidatesCount;
@@ -869,7 +869,7 @@ var Webinar = _webexCore.WebexPlugin.extend({
869
869
  }, _callee1);
870
870
  }))();
871
871
  },
872
- version: "3.12.0-next.78"
872
+ version: "3.12.0-next.79"
873
873
  });
874
874
  var _default = exports.default = Webinar;
875
875
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -94,5 +94,5 @@
94
94
  "//": [
95
95
  "TODO: upgrade jwt-decode when moving to node 18"
96
96
  ],
97
- "version": "3.12.0-next.78"
97
+ "version": "3.12.0-next.79"
98
98
  }
@@ -191,6 +191,7 @@ import AIEnableRequest from '../aiEnableRequest';
191
191
  const DEFAULT_ICE_PHASE_CALLBACK = () => 'JOIN_MEETING_FINAL';
192
192
 
193
193
  const LLM_HEALTHCHECK_TIMER_MS = 3 * 60 * 1000;
194
+ const JOIN_WITH_MEDIA_RETRY_MAX_COUNT = 2;
194
195
 
195
196
  const logRequest = (request: any, {logText = ''}) => {
196
197
  LoggerProxy.logger.info(`${logText} - sending request`);
@@ -794,7 +795,13 @@ export default class Meeting extends StatelessWebexPlugin {
794
795
  private deferSDPAnswer?: Defer; // used for waiting for a response
795
796
  private sdpResponseTimer?: ReturnType<typeof setTimeout>;
796
797
  private hasMediaConnectionConnectedAtLeastOnce: boolean;
797
- private joinWithMediaRetryInfo?: {isRetry: boolean; prevJoinResponse?: any};
798
+ private joinWithMediaRetryInfo: {
799
+ retryCount: number;
800
+ prevJoinResponse?: any;
801
+ firstError?: Error;
802
+ prevError?: Error;
803
+ };
804
+
798
805
  private connectionStateHandler?: ConnectionStateHandler;
799
806
  private iceCandidateErrors: Map<string, number>;
800
807
  private iceCandidatesCount: number;
@@ -1668,11 +1675,11 @@ export default class Meeting extends StatelessWebexPlugin {
1668
1675
  /**
1669
1676
  * Information needed for a retry of a call to joinWithMedia
1670
1677
  * @instance
1671
- * @type {{isRetry: boolean; prevJoinResponse?: any}}
1678
+ * @type {{retryCount: number; prevJoinResponse?: any}}
1672
1679
  * @private
1673
1680
  * @memberof Meeting
1674
1681
  */
1675
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
1682
+ this.joinWithMediaRetryInfo = {retryCount: 0, prevJoinResponse: undefined};
1676
1683
 
1677
1684
  /**
1678
1685
  * Connection state handler
@@ -5576,7 +5583,7 @@ export default class Meeting extends StatelessWebexPlugin {
5576
5583
  } = {}
5577
5584
  ) {
5578
5585
  const {mediaOptions, joinOptions = {}} = options;
5579
- const {isRetry, prevJoinResponse} = this.joinWithMediaRetryInfo;
5586
+ const {retryCount, prevJoinResponse, prevError} = this.joinWithMediaRetryInfo;
5580
5587
 
5581
5588
  if (!mediaOptions?.allowMediaInLobby) {
5582
5589
  return Promise.reject(
@@ -5602,12 +5609,17 @@ export default class Meeting extends StatelessWebexPlugin {
5602
5609
  );
5603
5610
  }
5604
5611
 
5612
+ const shouldJoin =
5613
+ !joinResponse || // first try, when the join response is empty
5614
+ (prevError && prevError instanceof UserNotJoinedError) || // last try failed with UserNotJoinedError
5615
+ MeetingUtil.isUserInLeftState(this.locusInfo); // locus dropped the connection before we can re-try addMedia
5616
+
5605
5617
  try {
5606
5618
  let turnServerInfo;
5607
5619
  let turnDiscoverySkippedReason;
5608
5620
  let forceTurnDiscovery = false;
5609
5621
 
5610
- if (!joinResponse) {
5622
+ if (shouldJoin) {
5611
5623
  // This is the 1st attempt or a retry after join request failed -> we need to do a join with TURN discovery
5612
5624
 
5613
5625
  const turnDiscoveryRequest = await this.roap.generateTurnDiscoveryRequestMessage(
@@ -5648,14 +5660,17 @@ export default class Meeting extends StatelessWebexPlugin {
5648
5660
 
5649
5661
  const mediaResponse = await this.addMediaInternal(
5650
5662
  () => {
5651
- return this.joinWithMediaRetryInfo.isRetry ? 'JOIN_MEETING_FINAL' : 'JOIN_MEETING_RETRY';
5663
+ // callback is not called when UserNotJoinedError is thrown
5664
+ return this.joinWithMediaRetryInfo.retryCount >= 1
5665
+ ? 'JOIN_MEETING_FINAL'
5666
+ : 'JOIN_MEETING_RETRY';
5652
5667
  },
5653
5668
  turnServerInfo,
5654
5669
  forceTurnDiscovery,
5655
5670
  mediaOptions
5656
5671
  );
5657
5672
 
5658
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
5673
+ this.joinWithMediaRetryInfo = {retryCount: 0, prevJoinResponse: undefined};
5659
5674
 
5660
5675
  return {
5661
5676
  join: joinResponse,
@@ -5669,8 +5684,10 @@ export default class Meeting extends StatelessWebexPlugin {
5669
5684
 
5670
5685
  this.roap.abortTurnDiscovery();
5671
5686
 
5672
- // if this was the first attempt, let's do a retry
5673
- let shouldRetry = !isRetry;
5687
+ // let's do a retry
5688
+ let shouldRetry =
5689
+ retryCount < 1 ||
5690
+ (error instanceof UserNotJoinedError && retryCount < JOIN_WITH_MEDIA_RETRY_MAX_COUNT);
5674
5691
 
5675
5692
  if (
5676
5693
  CallDiagnosticUtils.isSdpOfferCreationError(error) ||
@@ -5695,8 +5712,8 @@ export default class Meeting extends StatelessWebexPlugin {
5695
5712
  });
5696
5713
  }
5697
5714
 
5698
- // we only want to call leave if join was successful and this was a retry or we won't be doing any more retries
5699
- if (joined && (isRetry || !shouldRetry)) {
5715
+ // we only want to call leave if join was successful, and we won't be doing any more retries
5716
+ if (joined && !shouldRetry) {
5700
5717
  try {
5701
5718
  await this.leave({resourceId: joinOptions?.resourceId, reason: 'joinWithMedia failure'});
5702
5719
  } catch (e) {
@@ -5713,7 +5730,7 @@ export default class Meeting extends StatelessWebexPlugin {
5713
5730
  reason: error.message,
5714
5731
  stack: error.stack,
5715
5732
  leaveErrorReason: leaveError?.message,
5716
- isRetry,
5733
+ isRetry: retryCount > 0,
5717
5734
  },
5718
5735
  {
5719
5736
  type: error.name,
@@ -5722,15 +5739,26 @@ export default class Meeting extends StatelessWebexPlugin {
5722
5739
 
5723
5740
  if (shouldRetry) {
5724
5741
  LoggerProxy.logger.warn('Meeting:index#joinWithMedia --> retrying call to joinWithMedia');
5725
- this.joinWithMediaRetryInfo.isRetry = true;
5742
+ this.joinWithMediaRetryInfo.retryCount = retryCount + 1;
5726
5743
  this.joinWithMediaRetryInfo.prevJoinResponse = joinResponse;
5744
+ this.joinWithMediaRetryInfo.prevError = error;
5745
+ if (!this.joinWithMediaRetryInfo.firstError) {
5746
+ this.joinWithMediaRetryInfo.firstError = error;
5747
+ }
5727
5748
 
5728
5749
  return this.joinWithMedia(options);
5729
5750
  }
5730
5751
 
5731
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
5752
+ const {firstError} = this.joinWithMediaRetryInfo;
5732
5753
 
5733
- throw error;
5754
+ this.joinWithMediaRetryInfo = {
5755
+ retryCount: 0,
5756
+ prevJoinResponse: undefined,
5757
+ firstError: undefined,
5758
+ prevError: undefined,
5759
+ };
5760
+
5761
+ throw firstError ?? error;
5734
5762
  }
5735
5763
  }
5736
5764
 
@@ -8731,7 +8759,7 @@ export default class Meeting extends StatelessWebexPlugin {
8731
8759
  numTransports,
8732
8760
  isMultistream: this.isMultistream,
8733
8761
  retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
8734
- isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry,
8762
+ isJoinWithMediaRetry: this.joinWithMediaRetryInfo.retryCount > 0,
8735
8763
  ...reachabilityMetrics,
8736
8764
  ...iceCandidateErrors,
8737
8765
  iceCandidatesCount: this.iceCandidatesCount,
@@ -8776,7 +8804,7 @@ export default class Meeting extends StatelessWebexPlugin {
8776
8804
  turnServerUsed: this.turnServerUsed,
8777
8805
  retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
8778
8806
  isMultistream: this.isMultistream,
8779
- isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry,
8807
+ isJoinWithMediaRetry: this.joinWithMediaRetryInfo.retryCount > 0,
8780
8808
  signalingState:
8781
8809
  this.mediaProperties.webrtcMediaConnection?.multistreamConnection?.pc?.pc
8782
8810
  ?.signalingState ||
@@ -963,7 +963,7 @@ describe('plugin-meetings', () => {
963
963
 
964
964
  // resets joinWithMediaRetryInfo
965
965
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
966
- isRetry: false,
966
+ retryCount: 0,
967
967
  prevJoinResponse: undefined,
968
968
  });
969
969
  });
@@ -1035,7 +1035,12 @@ describe('plugin-meetings', () => {
1035
1035
  meeting.join = sinon.stub().returns(Promise.reject(error));
1036
1036
  meeting.locusUrl = null; // when join fails, we end up with null locusUrl
1037
1037
 
1038
- await assert.isRejected(meeting.joinWithMedia({mediaOptions: {allowMediaInLobby: true}}));
1038
+ const thrownError = await assert.isRejected(
1039
+ meeting.joinWithMedia({mediaOptions: {allowMediaInLobby: true}})
1040
+ );
1041
+
1042
+ // should throw the first attempt's error
1043
+ assert.equal(thrownError, error);
1039
1044
 
1040
1045
  assert.calledTwice(abortTurnDiscoveryStub);
1041
1046
 
@@ -1073,11 +1078,79 @@ describe('plugin-meetings', () => {
1073
1078
 
1074
1079
  // resets joinWithMediaRetryInfo
1075
1080
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
1076
- isRetry: false,
1081
+ retryCount: 0,
1077
1082
  prevJoinResponse: undefined,
1083
+ firstError: undefined,
1084
+ prevError: undefined,
1078
1085
  });
1079
1086
  });
1080
1087
 
1088
+ it('should re-join on retry when join() fails on first attempt, and throw the first error if join fails again', async () => {
1089
+ const firstJoinError = new Error('first join error');
1090
+ const secondJoinError = new Error('second join error');
1091
+
1092
+ meeting.join = sinon
1093
+ .stub()
1094
+ .onFirstCall()
1095
+ .rejects(firstJoinError)
1096
+ .onSecondCall()
1097
+ .rejects(secondJoinError);
1098
+ meeting.locusUrl = null; // join never succeeds
1099
+
1100
+ const thrownError = await assert.isRejected(
1101
+ meeting.joinWithMedia({joinOptions, mediaOptions})
1102
+ );
1103
+
1104
+ // join() should be called twice — once for the first attempt, once for the re-join
1105
+ assert.calledTwice(meeting.join);
1106
+ // TURN discovery should be attempted twice (once per join)
1107
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1108
+
1109
+ // should throw the first attempt's error, not the second
1110
+ assert.equal(thrownError, firstJoinError);
1111
+
1112
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1113
+ assert.calledWith(
1114
+ Metrics.sendBehavioralMetric.firstCall,
1115
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1116
+ sinon.match({reason: firstJoinError.message, isRetry: false}),
1117
+ sinon.match.any
1118
+ );
1119
+ assert.calledWith(
1120
+ Metrics.sendBehavioralMetric.secondCall,
1121
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1122
+ sinon.match({reason: secondJoinError.message, isRetry: true}),
1123
+ sinon.match.any
1124
+ );
1125
+
1126
+ assert.deepEqual(meeting.joinWithMediaRetryInfo, {
1127
+ retryCount: 0,
1128
+ prevJoinResponse: undefined,
1129
+ firstError: undefined,
1130
+ prevError: undefined,
1131
+ });
1132
+ });
1133
+
1134
+ it('should NOT call join() again on retry when join() succeeded but addMediaInternal() failed', async () => {
1135
+ const addMediaError = new Error('addMedia error');
1136
+
1137
+ meeting.addMediaInternal = sinon
1138
+ .stub()
1139
+ .onFirstCall()
1140
+ .rejects(addMediaError)
1141
+ .onSecondCall()
1142
+ .resolves(test4);
1143
+
1144
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1145
+
1146
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1147
+
1148
+ // join() should only be called once — the successful join result is reused on retry
1149
+ assert.calledOnce(meeting.join);
1150
+ // TURN discovery request is only generated once (on the first attempt alongside join)
1151
+ assert.calledOnce(generateTurnDiscoveryRequestMessageStub);
1152
+ });
1153
+
1081
1154
  it('should resolve if join() fails the first time but succeeds the second time', async () => {
1082
1155
  const error = new Error('fake');
1083
1156
  meeting.join = sinon
@@ -1119,7 +1192,7 @@ describe('plugin-meetings', () => {
1119
1192
 
1120
1193
  // resets joinWithMediaRetryInfo
1121
1194
  assert.deepEqual(meeting.joinWithMediaRetryInfo, {
1122
- isRetry: false,
1195
+ retryCount: 0,
1123
1196
  prevJoinResponse: undefined,
1124
1197
  });
1125
1198
  });
@@ -1188,6 +1261,127 @@ describe('plugin-meetings', () => {
1188
1261
  );
1189
1262
  });
1190
1263
 
1264
+ it('should throw the first attempt error when retry also fails with a different error', async () => {
1265
+ const firstError = new Error('first attempt error');
1266
+ const secondError = new Error('second attempt error');
1267
+
1268
+ const addMediaInternalResults = [];
1269
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1270
+ const defer = new Defer();
1271
+ addMediaInternalResults.push(defer);
1272
+ return defer.promise;
1273
+ });
1274
+
1275
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1276
+
1277
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1278
+
1279
+ await testUtils.flushPromises();
1280
+
1281
+ // 1st attempt fails
1282
+ addMediaInternalResults[0].reject(firstError);
1283
+ await testUtils.flushPromises();
1284
+
1285
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1286
+ assert.notCalled(leaveStub);
1287
+
1288
+ // 2nd (final) attempt fails
1289
+ addMediaInternalResults[1].reject(secondError);
1290
+ const thrownError = await assert.isRejected(result);
1291
+
1292
+ // should throw the first error, not the second
1293
+ assert.equal(thrownError, firstError);
1294
+
1295
+ // leave() should only be called after the last (2nd) attempt
1296
+ assert.calledOnce(leaveStub);
1297
+
1298
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1299
+ });
1300
+
1301
+ it('should throw the UserNotJoinedError as firstError when it occurs on the 1st attempt and 2nd attempt fails differently', async () => {
1302
+ const userNotJoinedError = new UserNotJoinedError();
1303
+ const secondError = new Error('second attempt error');
1304
+
1305
+ const addMediaInternalResults = [];
1306
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1307
+ const defer = new Defer();
1308
+ addMediaInternalResults.push(defer);
1309
+ return defer.promise;
1310
+ });
1311
+
1312
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1313
+
1314
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1315
+
1316
+ await testUtils.flushPromises();
1317
+
1318
+ // 1st attempt fails with UserNotJoinedError — triggers a re-join
1319
+ addMediaInternalResults[0].reject(userNotJoinedError);
1320
+ await testUtils.flushPromises();
1321
+
1322
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1323
+ assert.notCalled(leaveStub);
1324
+
1325
+ // 2nd (final) attempt fails
1326
+ addMediaInternalResults[1].reject(secondError);
1327
+ const thrownError = await assert.isRejected(result);
1328
+
1329
+ // should throw the first (UserNotJoinedError), not the second
1330
+ assert.equal(thrownError, userNotJoinedError);
1331
+
1332
+ // join() called twice: original + re-join triggered by UserNotJoinedError in prevError
1333
+ assert.calledTwice(meeting.join);
1334
+ // leave() should only be called after the last (2nd) attempt
1335
+ assert.calledOnce(leaveStub);
1336
+ assert.calledTwice(Metrics.sendBehavioralMetric);
1337
+ });
1338
+
1339
+ it('should throw the first UserNotJoinedError when it occurs on both 1st and 2nd attempts and a 3rd attempt also fails', async () => {
1340
+ const firstUserNotJoinedError = new UserNotJoinedError('first');
1341
+ const secondUserNotJoinedError = new UserNotJoinedError('second');
1342
+ const thirdError = new Error('third attempt error');
1343
+
1344
+ const addMediaInternalResults = [];
1345
+ meeting.addMediaInternal = sinon.stub().callsFake(() => {
1346
+ const defer = new Defer();
1347
+ addMediaInternalResults.push(defer);
1348
+ return defer.promise;
1349
+ });
1350
+
1351
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1352
+
1353
+ const result = meeting.joinWithMedia({joinOptions, mediaOptions});
1354
+
1355
+ await testUtils.flushPromises();
1356
+
1357
+ // 1st attempt fails with UserNotJoinedError — triggers a re-join
1358
+ addMediaInternalResults[0].reject(firstUserNotJoinedError);
1359
+ await testUtils.flushPromises();
1360
+
1361
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1362
+ assert.notCalled(leaveStub);
1363
+
1364
+ // 2nd attempt fails with UserNotJoinedError again — triggers another re-join
1365
+ addMediaInternalResults[1].reject(secondUserNotJoinedError);
1366
+ await testUtils.flushPromises();
1367
+
1368
+ // leave() should NOT be called after the 2nd attempt (intermediate retry)
1369
+ assert.notCalled(leaveStub);
1370
+
1371
+ // 3rd (final) attempt fails
1372
+ addMediaInternalResults[2].reject(thirdError);
1373
+ const thrownError = await assert.isRejected(result);
1374
+
1375
+ // should throw the very first error across all 3 attempts
1376
+ assert.equal(thrownError, firstUserNotJoinedError);
1377
+
1378
+ // join() called 3 times: original + 2 re-joins triggered by prevError being UserNotJoinedError
1379
+ assert.calledThrice(meeting.join);
1380
+ // leave() should only be called after the last (3rd) attempt
1381
+ assert.calledOnce(leaveStub);
1382
+ assert.calledThrice(Metrics.sendBehavioralMetric);
1383
+ });
1384
+
1191
1385
  it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
1192
1386
  const addMediaError = new Error('fake addMedia error');
1193
1387
  addMediaError.name = 'TypeError'; // This makes it a browser media error
@@ -1435,7 +1629,8 @@ describe('plugin-meetings', () => {
1435
1629
 
1436
1630
  await testUtils.flushPromises();
1437
1631
 
1438
- // check the callback works correctly on the 2nd attempt
1632
+ // check the callback works correctly on the 2nd attempt:
1633
+ // retryCount=1 with a non-UserNotJoinedError is terminal, so icePhase must be JOIN_MEETING_FINAL
1439
1634
  assert.equal(icePhaseCallbacks.length, 2);
1440
1635
  assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
1441
1636
 
@@ -1445,6 +1640,118 @@ describe('plugin-meetings', () => {
1445
1640
  await assert.isRejected(result);
1446
1641
  });
1447
1642
 
1643
+ it('should allow an additional retry when UserNotJoinedError occurs and return JOIN_MEETING_FINAL on the 3rd attempt', async () => {
1644
+ const genericError = new Error('generic error');
1645
+ const userNotJoinedError = new UserNotJoinedError();
1646
+ const thirdError = new Error('third error');
1647
+
1648
+ const icePhaseCallbacks = [];
1649
+ const addMediaInternalResults = [];
1650
+
1651
+ meeting.addMediaInternal = sinon
1652
+ .stub()
1653
+ .callsFake((icePhaseCallback) => {
1654
+ const defer = new Defer();
1655
+
1656
+ icePhaseCallbacks.push(icePhaseCallback);
1657
+ addMediaInternalResults.push(defer);
1658
+ return defer.promise;
1659
+ });
1660
+
1661
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1662
+
1663
+ const result = meeting.joinWithMedia({
1664
+ joinOptions,
1665
+ mediaOptions,
1666
+ });
1667
+
1668
+ await testUtils.flushPromises();
1669
+
1670
+ // 1st attempt: retryCount=0 → JOIN_MEETING_RETRY
1671
+ assert.equal(icePhaseCallbacks.length, 1);
1672
+ assert.equal(icePhaseCallbacks[0](), 'JOIN_MEETING_RETRY');
1673
+ addMediaInternalResults[0].reject(genericError);
1674
+
1675
+ await testUtils.flushPromises();
1676
+
1677
+ // leave() should NOT be called after the 1st attempt (intermediate retry)
1678
+ assert.notCalled(leaveStub);
1679
+
1680
+ // 2nd attempt: retryCount=1 → JOIN_MEETING_FINAL
1681
+ // (In real usage this callback is never invoked when UserNotJoinedError is thrown,
1682
+ // because UserNotJoinedError is thrown before waitForMediaConnectionConnected() is reached.)
1683
+ assert.equal(icePhaseCallbacks.length, 2);
1684
+ assert.equal(icePhaseCallbacks[1](), 'JOIN_MEETING_FINAL');
1685
+ addMediaInternalResults[1].reject(userNotJoinedError);
1686
+
1687
+ await testUtils.flushPromises();
1688
+
1689
+ // leave() should NOT be called after the 2nd attempt (intermediate retry)
1690
+ assert.notCalled(leaveStub);
1691
+
1692
+ // 3rd attempt: retryCount=2 → JOIN_MEETING_FINAL
1693
+ assert.equal(icePhaseCallbacks.length, 3);
1694
+ assert.equal(icePhaseCallbacks[2](), 'JOIN_MEETING_FINAL');
1695
+ addMediaInternalResults[2].reject(thirdError);
1696
+
1697
+ const thrownError = await assert.isRejected(result);
1698
+
1699
+ // should throw the first error
1700
+ assert.equal(thrownError, genericError);
1701
+
1702
+ // leave() should only be called once, after the last (3rd) attempt
1703
+ assert.calledOnce(leaveStub);
1704
+ });
1705
+
1706
+ it('should re-join when retrying after a UserNotJoinedError', async () => {
1707
+ const userNotJoinedError = new UserNotJoinedError();
1708
+
1709
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1710
+
1711
+ meeting.addMediaInternal = sinon
1712
+ .stub()
1713
+ .onFirstCall()
1714
+ .rejects(userNotJoinedError)
1715
+ .onSecondCall()
1716
+ .resolves(test4);
1717
+
1718
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1719
+
1720
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1721
+
1722
+ // join() should be called twice — once for the first attempt, once for the re-join after UserNotJoinedError
1723
+ assert.calledTwice(meeting.join);
1724
+ // TURN discovery should be attempted twice (once per join)
1725
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1726
+ // leave() should never be called when retrying after UserNotJoinedError
1727
+ assert.notCalled(leaveStub);
1728
+ });
1729
+
1730
+ it('should re-join when isUserInLeftState returns true on retry', async () => {
1731
+ const addMediaError = new Error('addMedia error');
1732
+
1733
+ sinon.stub(MeetingUtil, 'isUserInLeftState').returns(true);
1734
+
1735
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1736
+
1737
+ meeting.addMediaInternal = sinon
1738
+ .stub()
1739
+ .onFirstCall()
1740
+ .rejects(addMediaError)
1741
+ .onSecondCall()
1742
+ .resolves(test4);
1743
+
1744
+ const result = await meeting.joinWithMedia({joinOptions, mediaOptions});
1745
+
1746
+ assert.deepEqual(result, {join: fakeJoinResult, media: test4, multistreamEnabled: true});
1747
+
1748
+ // join() should be called twice — once for the first attempt, once because the user is in left state
1749
+ assert.calledTwice(meeting.join);
1750
+ assert.calledTwice(generateTurnDiscoveryRequestMessageStub);
1751
+ // leave() should never be called when retrying after isUserInLeftState returns true
1752
+ assert.notCalled(leaveStub);
1753
+ });
1754
+
1448
1755
  [
1449
1756
  {
1450
1757
  errorName: 'SdpOfferCreationError',