@stream-io/video-client 1.60.0 → 1.61.0
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.
- package/CHANGELOG.md +11 -0
- package/dist/index.browser.es.js +253 -55
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +253 -54
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +253 -55
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +11 -5
- package/dist/src/rtc/Publisher.d.ts +18 -0
- package/dist/src/rtc/TransceiverCache.d.ts +10 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
- package/dist/src/types.d.ts +30 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +180 -48
- package/src/__tests__/Call.lifecycle.test.ts +479 -9
- package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Publisher.test.ts +209 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
- package/src/rtc/e2ee/E2EEManager.ts +10 -1
- package/src/rtc/e2ee/EncryptionManager.ts +1 -1
- package/src/rtc/e2ee/SPEC.md +1 -1
- package/src/types.ts +37 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
});
|