@stream-io/video-client 1.60.0 → 1.61.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.
@@ -5,15 +5,61 @@
5
5
  import '../rtc/__tests__/mocks/webrtc.mocks';
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { fromPartial } from '@total-typescript/shoehorn';
8
9
  import { Call } from '../Call';
10
+ import * as sfu from '../StreamSfuClient';
11
+ import * as rtc from '../rtc';
9
12
  import { StreamClient } from '../coordinator/connection/client';
13
+ import { ErrorFromResponse } from '../coordinator/connection/types';
10
14
  import { ClientEventReporter } from '../reporting';
15
+ import type { GetCallResponse, JoinCallResponse } from '../gen/coordinator';
16
+ import { WebsocketReconnectStrategy } from '../gen/video/sfu/models/models';
11
17
  import { generateUUIDv4 } from '../coordinator/connection/utils';
12
- import { StreamVideoWriteableStateStore } from '../store';
18
+ import { CallingState, StreamVideoWriteableStateStore } from '../store';
13
19
  import { promiseWithResolvers } from '../helpers/promise';
14
20
 
21
+ // A controlled stand-in for the retry backoff. Unless a test installs a hook,
22
+ // the real `sleep` is used, so the rest of the suite is unaffected.
23
+ const sleepControl: { onSleep?: (ms: number) => Promise<unknown> } = {};
24
+ vi.mock('../coordinator/connection/utils', async (importOriginal) => {
25
+ const actual =
26
+ await importOriginal<typeof import('../coordinator/connection/utils')>();
27
+ return {
28
+ ...actual,
29
+ sleep: (ms: number) =>
30
+ sleepControl.onSleep ? sleepControl.onSleep(ms) : actual.sleep(ms),
31
+ };
32
+ });
33
+
34
+ // the React Native globals that `join()`/`leave()` reach for when the SDK runs
35
+ // on a device. Installed only by the tests that assert on callingX behavior.
36
+ const installCallingX = () => {
37
+ const callingX = {
38
+ joinCall: vi.fn().mockResolvedValue(undefined),
39
+ endCall: vi.fn(),
40
+ wireAudioEngineSubscription: vi.fn(),
41
+ };
42
+ (globalThis as Record<string, any>).streamRNVideoSDK = {
43
+ callingX,
44
+ callManager: { stop: vi.fn() },
45
+ };
46
+ return callingX;
47
+ };
48
+
49
+ const makePeer = () => ({
50
+ detachEventHandlers: vi.fn(),
51
+ dispose: vi.fn().mockResolvedValue(undefined),
52
+ });
53
+
15
54
  describe('Call lifecycle wiring', () => {
16
55
  let call: Call;
56
+ let internalCall: {
57
+ doJoin: Call['join'];
58
+ reconnectFast: () => Promise<void>;
59
+ restorePublishedTracks: () => Promise<void>;
60
+ restoreSubscribedTracks: () => void;
61
+ applyDeviceConfig: () => Promise<void>;
62
+ };
17
63
 
18
64
  beforeEach(() => {
19
65
  vi.spyOn(globalThis, 'fetch').mockResolvedValue({
@@ -30,12 +76,20 @@ describe('Call lifecycle wiring', () => {
30
76
  }),
31
77
  clientStore: new StreamVideoWriteableStateStore(),
32
78
  });
79
+ internalCall = call as unknown as typeof internalCall;
33
80
  });
34
81
 
35
82
  afterEach(() => {
36
83
  vi.restoreAllMocks();
84
+ delete sleepControl.onSleep;
85
+ delete (globalThis as Record<string, any>).streamRNVideoSDK;
37
86
  });
38
87
 
88
+ const mockJoin = () => {
89
+ vi.spyOn(call, 'setup').mockResolvedValue(undefined);
90
+ return vi.spyOn(internalCall, 'doJoin').mockResolvedValue(undefined);
91
+ };
92
+
39
93
  // Regression guard for the Call-owned helper teardown chain. Each of
40
94
  // these helpers holds a resource (timer, listener, AudioContext) that
41
95
  // leaks across calls if teardown is dropped during a refactor.
@@ -82,10 +136,7 @@ describe('Call lifecycle wiring', () => {
82
136
  // `joinSource` is reporting-only: it must reach the event reporter and never
83
137
  // the coordinator's join request.
84
138
  it('call.join() reports joinSource without putting it on the wire', async () => {
85
- vi.spyOn(call, 'setup').mockResolvedValue(undefined);
86
- const doJoin = vi
87
- .spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin')
88
- .mockResolvedValue(undefined);
139
+ const doJoin = mockJoin();
89
140
  const withJoinLifecycle = vi.spyOn(
90
141
  call.clientEventReporter,
91
142
  'withJoinLifecycle',
@@ -104,10 +155,7 @@ describe('Call lifecycle wiring', () => {
104
155
 
105
156
  it('call.join() shares an in-flight join flow', async () => {
106
157
  const joinTask = promiseWithResolvers<void>();
107
- vi.spyOn(call, 'setup').mockResolvedValue(undefined);
108
- const doJoin = vi
109
- .spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin')
110
- .mockReturnValue(joinTask.promise);
158
+ const doJoin = mockJoin().mockReturnValue(joinTask.promise);
111
159
 
112
160
  const firstJoin = call.join();
113
161
  const secondJoin = call.join();
@@ -121,4 +169,426 @@ describe('Call lifecycle wiring', () => {
121
169
  undefined,
122
170
  ]);
123
171
  });
172
+
173
+ // Cover both a retryable attempt and the last allowed attempt.
174
+ it.each([3, 1])(
175
+ 'leave() cancels an in-flight join with maxJoinRetries=%s',
176
+ async (maxJoinRetries) => {
177
+ const callingX = installCallingX();
178
+ const pending = promiseWithResolvers<void>();
179
+ const doJoin = mockJoin().mockImplementationOnce(async () => {
180
+ await pending.promise;
181
+ throw new Error('join response timeout');
182
+ });
183
+
184
+ const task = call.join({ maxJoinRetries });
185
+ await vi.waitFor(() => expect(doJoin).toHaveBeenCalledTimes(1));
186
+ await call.leave();
187
+ expect(call.state.callingState).toBe(CallingState.LEFT);
188
+ pending.resolve();
189
+
190
+ await expect(task).resolves.toBeUndefined();
191
+ expect(doJoin).toHaveBeenCalledTimes(1);
192
+ expect(call.state.callingState).toBe(CallingState.LEFT);
193
+ expect(callingX.endCall).not.toHaveBeenCalledWith(call, 'error');
194
+ },
195
+ );
196
+
197
+ it('call.join() stops retrying when leave() lands during the backoff', async () => {
198
+ const backoff = promiseWithResolvers<void>();
199
+ sleepControl.onSleep = () => backoff.promise;
200
+ const doJoin = mockJoin().mockRejectedValue(new Error('transient failure'));
201
+
202
+ const joinTask = call.join({ maxJoinRetries: 3 });
203
+ await vi.waitFor(() => expect(doJoin).toHaveBeenCalledTimes(1));
204
+
205
+ // the user leaves while the retry loop waits out its backoff
206
+ await call.leave();
207
+ backoff.resolve();
208
+
209
+ await expect(joinTask).resolves.toBeUndefined();
210
+ expect(doJoin).toHaveBeenCalledTimes(1);
211
+ expect(call.state.callingState).toBe(CallingState.LEFT);
212
+ });
213
+
214
+ it.each(['callingX.joinCall', 'setup'] as const)(
215
+ 'call.join() stops before registration when leave() lands during %s',
216
+ async (phase) => {
217
+ const pending = promiseWithResolvers<void>();
218
+ const callingX = installCallingX();
219
+ // Keep real setup for the native-join case to detect reinitialization.
220
+ const setup = vi.spyOn(call, 'setup');
221
+ const paused = phase === 'setup' ? setup : callingX.joinCall;
222
+ paused.mockReturnValue(pending.promise);
223
+ const registerCall = vi.spyOn(call.clientEventReporter, 'registerCall');
224
+ const doJoin = vi
225
+ .spyOn(internalCall, 'doJoin')
226
+ .mockResolvedValue(undefined);
227
+
228
+ const task = call.join();
229
+ await vi.waitFor(() => expect(paused).toHaveBeenCalled());
230
+ await call.leave();
231
+ expect(call.state.callingState).toBe(CallingState.LEFT);
232
+ pending.resolve();
233
+
234
+ await expect(task).resolves.toBeUndefined();
235
+ if (phase === 'callingX.joinCall') expect(setup).not.toHaveBeenCalled();
236
+ expect(registerCall).not.toHaveBeenCalled();
237
+ expect(doJoin).not.toHaveBeenCalled();
238
+ expect(call.state.callingState).toBe(CallingState.LEFT);
239
+ expect(callingX.endCall).not.toHaveBeenCalledWith(call, 'error');
240
+ },
241
+ );
242
+
243
+ it.each([
244
+ 'media factory',
245
+ 'location hint',
246
+ 'coordinator request',
247
+ 'coordinator rejection',
248
+ 'acceptance',
249
+ 'generic SDP',
250
+ 'SFU join',
251
+ 'SFU rejection',
252
+ ] as const)('call.join() stops after leave() during %s', async (phase) => {
253
+ const pending = promiseWithResolvers<void>();
254
+ const stage =
255
+ phase === 'coordinator rejection'
256
+ ? 'coordinator request'
257
+ : phase === 'SFU rejection'
258
+ ? 'SFU join'
259
+ : phase;
260
+ const responseAt = async <T>(step: typeof stage, response: T) => {
261
+ if (stage === step) {
262
+ await pending.promise;
263
+ if (phase.endsWith('rejection')) throw new Error('Join failed');
264
+ }
265
+ return response;
266
+ };
267
+ vi.spyOn(call, 'setup').mockResolvedValue(undefined);
268
+ const mediaFactory = vi.spyOn(call, 'ensureMediaFactory');
269
+ const callingX =
270
+ phase === 'media factory' || phase === 'SFU rejection'
271
+ ? installCallingX()
272
+ : undefined;
273
+ if (phase === 'media factory') {
274
+ mediaFactory.mockImplementation(() =>
275
+ responseAt('media factory', fromPartial<rtc.CallMediaEngine>({})),
276
+ );
277
+ }
278
+ const locationHint = vi
279
+ .spyOn(call.streamClient, 'getLocationHint')
280
+ .mockImplementation(() => responseAt('location hint', 'AMS'));
281
+ vi.spyOn(call.streamClient, '_hasConnectionID').mockReturnValue(true);
282
+ const updateState = vi.spyOn(call.state, 'updateFromCallResponse');
283
+ const accept = vi
284
+ .spyOn(call, 'accept')
285
+ .mockImplementation(() => responseAt('acceptance', { duration: '0ms' }));
286
+ const registerCall = vi.spyOn(call.clientStore, 'registerOrUpdateCall');
287
+ const request = vi.spyOn(call.streamClient, 'post').mockImplementation(() =>
288
+ responseAt(
289
+ 'coordinator request',
290
+ fromPartial<JoinCallResponse>({
291
+ call: { egress: {}, custom: {}, created_by: { id: 'other-user' } },
292
+ members: [],
293
+ own_capabilities: [],
294
+ stats_options: { enable_rtc_stats: false },
295
+ }),
296
+ ),
297
+ );
298
+ const genericSdp = vi
299
+ .spyOn(rtc, 'getGenericSdp')
300
+ .mockImplementation(() => responseAt('generic SDP', 'sdp'));
301
+ const updateSfuState = vi
302
+ .spyOn(call.state, 'updateFromSfuCallState')
303
+ .mockImplementation(() => {});
304
+ const joinSfu = vi.fn(() =>
305
+ responseAt('SFU join', {
306
+ callState: {},
307
+ publishOptions: [],
308
+ fastReconnectDeadlineSeconds: 123,
309
+ }),
310
+ );
311
+ const closeSfu = vi.fn();
312
+ const createSfu = vi
313
+ .spyOn(sfu, 'StreamSfuClient')
314
+ .mockImplementation(function () {
315
+ return fromPartial<sfu.StreamSfuClient>({
316
+ sessionId: 'test-session',
317
+ join: joinSfu,
318
+ close: closeSfu,
319
+ leaveAndClose: vi.fn().mockResolvedValue(undefined),
320
+ });
321
+ });
322
+
323
+ const joinTask = call.join({ ring: true, maxJoinRetries: 1 });
324
+ const pausedOperation = {
325
+ 'media factory': mediaFactory,
326
+ 'location hint': locationHint,
327
+ 'coordinator request': request,
328
+ acceptance: accept,
329
+ 'generic SDP': genericSdp,
330
+ 'SFU join': joinSfu,
331
+ }[stage];
332
+ await vi.waitFor(() => expect(pausedOperation).toHaveBeenCalled());
333
+ expect(call.state.callingState).toBe(CallingState.JOINING);
334
+ await call.leave();
335
+ const publishOptions = call['currentPublishOptions'];
336
+ const reconnectDeadline = call['fastReconnectDeadlineSeconds'];
337
+ pending.resolve();
338
+
339
+ await expect(joinTask).resolves.toBeUndefined();
340
+ if (phase === 'media factory') {
341
+ expect(callingX!.wireAudioEngineSubscription).not.toHaveBeenCalled();
342
+ }
343
+ if (phase === 'media factory' || phase === 'location hint') {
344
+ expect(request).not.toHaveBeenCalled();
345
+ }
346
+ if (
347
+ phase === 'coordinator request' ||
348
+ phase === 'media factory' ||
349
+ phase === 'location hint'
350
+ ) {
351
+ expect(createSfu).not.toHaveBeenCalled();
352
+ expect(updateState).not.toHaveBeenCalled();
353
+ expect(accept).not.toHaveBeenCalled();
354
+ expect(registerCall).not.toHaveBeenCalled();
355
+ }
356
+ if (phase === 'acceptance') expect(registerCall).not.toHaveBeenCalled();
357
+ expect(joinSfu).toHaveBeenCalledTimes(
358
+ phase === 'SFU join' || phase === 'SFU rejection' ? 1 : 0,
359
+ );
360
+ expect(updateSfuState).not.toHaveBeenCalled();
361
+ expect(call['currentPublishOptions']).toBe(publishOptions);
362
+ expect(call['fastReconnectDeadlineSeconds']).toBe(reconnectDeadline);
363
+ expect(call['sfuClient']).toBeUndefined();
364
+ expect(call.clientStore.calls).not.toContain(call);
365
+ expect(call.ringing).toBe(false);
366
+ expect(call.state.callingState).toBe(CallingState.LEFT);
367
+ if (phase === 'SFU rejection') {
368
+ expect(closeSfu).not.toHaveBeenCalled();
369
+ expect(callingX!.endCall).not.toHaveBeenCalledWith(call, 'error');
370
+ }
371
+ });
372
+
373
+ it.each([
374
+ ['FAST', 'join'],
375
+ ['REJOIN', 'join'],
376
+ ['MIGRATE', 'join'],
377
+ ['MIGRATE', 'migration'],
378
+ ] as const)(
379
+ 'stops %s reconnect after leave during %s',
380
+ async (strategy, phase) => {
381
+ const pending = promiseWithResolvers<void>();
382
+ const doJoin = vi
383
+ .spyOn(internalCall, 'doJoin')
384
+ .mockImplementation(async () => {
385
+ if (phase === 'join') await pending.promise;
386
+ });
387
+ const get = vi.spyOn(call, 'get').mockResolvedValue(fromPartial({}));
388
+ const restorePublished = vi
389
+ .spyOn(internalCall, 'restorePublishedTracks')
390
+ .mockResolvedValue(undefined);
391
+ const restoreSubscribed = vi
392
+ .spyOn(internalCall, 'restoreSubscribedTracks')
393
+ .mockImplementation(() => {});
394
+ const oldSfu = fromPartial<sfu.StreamSfuClient>({
395
+ enterMigration: vi.fn().mockReturnValue(pending.promise),
396
+ leaveAndClose: vi.fn().mockResolvedValue(undefined),
397
+ close: vi.fn(),
398
+ });
399
+ const oldSubscriber = fromPartial<rtc.Subscriber>(makePeer());
400
+ const oldPublisher = fromPartial<rtc.Publisher>(makePeer());
401
+ call['sfuClient'] = oldSfu;
402
+ call['subscriber'] = oldSubscriber;
403
+ call['publisher'] = oldPublisher;
404
+ call.clientStore.registerOrUpdateCall(call);
405
+ const task = call['reconnect'](
406
+ WebsocketReconnectStrategy[strategy],
407
+ 'test',
408
+ );
409
+ await vi.waitFor(() =>
410
+ expect(
411
+ phase === 'join' ? doJoin : restoreSubscribed,
412
+ ).toHaveBeenCalled(),
413
+ );
414
+ await call.leave();
415
+ vi.mocked(oldSubscriber.dispose).mockClear();
416
+ vi.mocked(oldPublisher.dispose).mockClear();
417
+ restoreSubscribed.mockClear();
418
+ pending.resolve();
419
+ await task;
420
+
421
+ expect(call.state.callingState).toBe(CallingState.LEFT);
422
+ expect(call.clientStore.calls).not.toContain(call);
423
+ expect(get).not.toHaveBeenCalled();
424
+ expect(restoreSubscribed).not.toHaveBeenCalled();
425
+ if (phase === 'join') expect(restorePublished).not.toHaveBeenCalled();
426
+ if (strategy === 'MIGRATE') {
427
+ expect(oldSubscriber.dispose).toHaveBeenCalledOnce();
428
+ expect(oldPublisher.dispose).toHaveBeenCalledOnce();
429
+ expect(oldSfu.close).toHaveBeenCalledOnce();
430
+ }
431
+ },
432
+ );
433
+
434
+ it.each(['reconnect failure', 'failure refresh', 'backoff'] as const)(
435
+ 'preserves LEFT when leave happens during %s',
436
+ async (phase) => {
437
+ const pending = promiseWithResolvers<void>();
438
+ const backoff = vi.fn(() => pending.promise);
439
+ if (phase === 'backoff') sleepControl.onSleep = backoff;
440
+ const reconnect = vi
441
+ .spyOn(internalCall, 'reconnectFast')
442
+ .mockImplementation(async () => {
443
+ if (phase === 'reconnect failure') await pending.promise;
444
+ throw new ErrorFromResponse(
445
+ fromPartial({ unrecoverable: phase !== 'backoff' }),
446
+ );
447
+ });
448
+ const get = vi.spyOn(call, 'get').mockImplementation(async () => {
449
+ await pending.promise;
450
+ return fromPartial<GetCallResponse>({});
451
+ });
452
+ const task = call['reconnect'](WebsocketReconnectStrategy.FAST, 'test');
453
+ const paused = {
454
+ 'reconnect failure': reconnect,
455
+ 'failure refresh': get,
456
+ backoff,
457
+ }[phase];
458
+ await vi.waitFor(() => expect(paused).toHaveBeenCalled());
459
+ await call.leave();
460
+ pending.resolve();
461
+ await task;
462
+
463
+ expect(call.state.callingState).toBe(CallingState.LEFT);
464
+ expect(call.clientStore.calls).not.toContain(call);
465
+ expect(get).toHaveBeenCalledTimes(phase === 'failure refresh' ? 1 : 0);
466
+ expect(reconnect).toHaveBeenCalledOnce();
467
+ expect(call['reconnectStrategy']).toBe(
468
+ WebsocketReconnectStrategy.UNSPECIFIED,
469
+ );
470
+ },
471
+ );
472
+
473
+ it.each([false, true])(
474
+ 'get() respects leave during fetch: %s',
475
+ async (leaveDuringFetch) => {
476
+ const pending = promiseWithResolvers<GetCallResponse>();
477
+ const response = fromPartial<GetCallResponse>({ call: { settings: {} } });
478
+ const request = vi
479
+ .spyOn(call.streamClient, 'get')
480
+ .mockReturnValue(pending.promise);
481
+ vi.spyOn(call.streamClient, '_hasConnectionID').mockReturnValue(true);
482
+ const update = vi
483
+ .spyOn(call, 'updateFromCallStateResponse')
484
+ .mockImplementation(() => {});
485
+ const configure = vi
486
+ .spyOn(internalCall, 'applyDeviceConfig')
487
+ .mockResolvedValue(undefined);
488
+ await call.leave();
489
+ const task = call.get(); // deliberate reuse must still work
490
+ await vi.waitFor(() => expect(request).toHaveBeenCalled());
491
+ if (leaveDuringFetch) await call.leave();
492
+ pending.resolve(response);
493
+ await expect(task).resolves.toBe(response);
494
+
495
+ expect(call.clientStore.calls.includes(call)).toBe(!leaveDuringFetch);
496
+ expect(update).toHaveBeenCalledTimes(leaveDuringFetch ? 0 : 1);
497
+ expect(configure).toHaveBeenCalledTimes(leaveDuringFetch ? 0 : 1);
498
+ expect(call.state.callingState).toBe(
499
+ leaveDuringFetch ? CallingState.LEFT : CallingState.IDLE,
500
+ );
501
+ if (!leaveDuringFetch) await call.leave();
502
+ },
503
+ );
504
+
505
+ it.each([
506
+ 'stats flush',
507
+ 'subscriber disposal',
508
+ 'publisher disposal',
509
+ ] as const)(
510
+ 'call.join() does not recreate peers after leave() during %s',
511
+ async (phase) => {
512
+ const pending = promiseWithResolvers<void>();
513
+ vi.spyOn(call, 'setup').mockResolvedValue(undefined);
514
+ call['credentials'] = fromPartial({ ice_servers: [] });
515
+ call['lastStatsOptions'] = fromPartial({ reporting_interval_ms: 0 });
516
+ call['sfuClient'] = fromPartial({
517
+ isHealthy: true,
518
+ sessionId: 'test-session',
519
+ leaveAndClose: vi.fn().mockResolvedValue(undefined),
520
+ });
521
+ const flush = vi.fn().mockResolvedValue(undefined);
522
+ const disposeSubscriber = vi.fn().mockResolvedValue(undefined);
523
+ const disposePublisher = vi.fn().mockResolvedValue(undefined);
524
+ call['sfuStatsReporter'] = fromPartial({ flush, stop: vi.fn() });
525
+ call['subscriber'] = fromPartial({ dispose: disposeSubscriber });
526
+ call['publisher'] = fromPartial({ dispose: disposePublisher });
527
+ const pause = {
528
+ 'stats flush': flush,
529
+ 'subscriber disposal': disposeSubscriber,
530
+ 'publisher disposal': disposePublisher,
531
+ }[phase];
532
+ // Only pause initialization; leave's own cleanup must remain able to finish.
533
+ pause.mockReturnValueOnce(pending.promise);
534
+ const createSubscriber = vi
535
+ .spyOn(rtc, 'Subscriber')
536
+ .mockImplementation(function () {
537
+ return fromPartial<rtc.Subscriber>(makePeer());
538
+ });
539
+ const createPublisher = vi
540
+ .spyOn(rtc, 'Publisher')
541
+ .mockImplementation(function () {
542
+ return fromPartial<rtc.Publisher>(makePeer());
543
+ });
544
+ const logInfo = vi.spyOn(call['logger'], 'info');
545
+
546
+ const joinTask = call.join();
547
+ await vi.waitFor(() => expect(pause).toHaveBeenCalledTimes(1));
548
+ const subscriberCount = createSubscriber.mock.calls.length;
549
+ const publisherCount = createPublisher.mock.calls.length;
550
+ await call.leave();
551
+ pending.resolve();
552
+
553
+ await expect(joinTask).resolves.toBeUndefined();
554
+ expect(createSubscriber).toHaveBeenCalledTimes(subscriberCount);
555
+ expect(createPublisher).toHaveBeenCalledTimes(publisherCount);
556
+ expect(call['subscriber']).toBeUndefined();
557
+ expect(call['publisher']).toBeUndefined();
558
+ expect(call['sfuStatsReporter']).toBeUndefined();
559
+ expect(call.state.callingState).toBe(CallingState.LEFT);
560
+ expect(logInfo).not.toHaveBeenCalledWith(`Joined call ${call.cid}`);
561
+ },
562
+ );
563
+
564
+ // Controls: without a leave, the retry loop must behave exactly as before.
565
+ it('call.join() still retries a recoverable failure', async () => {
566
+ sleepControl.onSleep = () => Promise.resolve();
567
+ const doJoin = mockJoin()
568
+ .mockRejectedValueOnce(new Error('transient failure'))
569
+ .mockResolvedValue(undefined);
570
+
571
+ await expect(call.join({ maxJoinRetries: 3 })).resolves.toBeUndefined();
572
+ expect(doJoin).toHaveBeenCalledTimes(2);
573
+ });
574
+
575
+ it('call.join() rejects and ends the call once retries are exhausted', async () => {
576
+ const callingX = installCallingX();
577
+ sleepControl.onSleep = () => Promise.resolve();
578
+ const doJoin = mockJoin().mockRejectedValue(new Error('transient failure'));
579
+
580
+ await expect(call.join({ maxJoinRetries: 2 })).rejects.toThrow(
581
+ 'transient failure',
582
+ );
583
+ expect(doJoin).toHaveBeenCalledTimes(2);
584
+ expect(callingX.endCall).toHaveBeenCalledWith(call, 'error');
585
+ });
586
+
587
+ it('call.join() is still allowed after a completed leave()', async () => {
588
+ const doJoin = mockJoin();
589
+
590
+ await call.leave();
591
+ await expect(call.join()).resolves.toBeUndefined();
592
+ expect(doJoin).toHaveBeenCalledTimes(1);
593
+ });
124
594
  });