@stream-io/video-client 1.48.0 → 1.50.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 +25 -0
- package/dist/index.browser.es.js +1497 -677
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1497 -677
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1497 -677
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +77 -4
- package/dist/src/StreamSfuClient.d.ts +8 -1
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +13 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
- package/dist/src/rtc/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +33 -1
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +268 -40
- package/src/StreamSfuClient.ts +75 -12
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/__tests__/Call.publishing.test.ts +103 -0
- package/src/__tests__/StreamSfuClient.test.ts +275 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +20 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devices.ts +2 -1
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rpc/retryable.ts +0 -1
- package/src/rtc/BasePeerConnection.ts +96 -6
- package/src/rtc/Publisher.ts +49 -2
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
- package/src/rtc/__tests__/Publisher.test.ts +332 -10
- package/src/rtc/__tests__/Subscriber.test.ts +202 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -0
- package/src/rtc/index.ts +1 -0
- package/src/rtc/types.ts +38 -1
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import './mocks/webrtc.mocks';
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Call } from '../../Call';
|
|
5
|
+
import { StreamClient } from '../../coordinator/connection/client';
|
|
6
|
+
import { StreamVideoWriteableStateStore } from '../../store';
|
|
7
|
+
import { CallingState } from '../../store';
|
|
8
|
+
import { NegotiationError } from '../NegotiationError';
|
|
9
|
+
import { ReconnectReason } from '../types';
|
|
10
|
+
import {
|
|
11
|
+
PeerType,
|
|
12
|
+
WebsocketReconnectStrategy,
|
|
13
|
+
ErrorCode,
|
|
14
|
+
} from '../../gen/video/sfu/models/models';
|
|
15
|
+
import * as connectionUtils from '../../coordinator/connection/utils';
|
|
16
|
+
import { Publisher } from '../Publisher';
|
|
17
|
+
import { Subscriber } from '../Subscriber';
|
|
18
|
+
import { Dispatcher } from '../Dispatcher';
|
|
19
|
+
import { StreamSfuClient } from '../../StreamSfuClient';
|
|
20
|
+
import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
21
|
+
|
|
22
|
+
vi.mock('../../StreamSfuClient', () => ({
|
|
23
|
+
StreamSfuClient: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const makeCall = () => {
|
|
27
|
+
const streamClient = new StreamClient('test-key');
|
|
28
|
+
const clientStore = new StreamVideoWriteableStateStore();
|
|
29
|
+
return new Call({
|
|
30
|
+
type: 'default',
|
|
31
|
+
id: 'test-call',
|
|
32
|
+
streamClient,
|
|
33
|
+
clientStore,
|
|
34
|
+
ringing: false,
|
|
35
|
+
watching: false,
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Primes the call so the reconnect loop will actually enter the
|
|
41
|
+
* strategy branch (loop guards on callingState != JOINED/LEFT/RECONNECTING_FAILED)
|
|
42
|
+
* and behaves deterministically.
|
|
43
|
+
*/
|
|
44
|
+
const primeForReconnect = (call: Call) => {
|
|
45
|
+
// put the call in a non-terminal, non-JOINED state so the do-while iterates
|
|
46
|
+
call.state.setCallingState(CallingState.JOINING);
|
|
47
|
+
// force the strategy-decider in the catch block to always pick REJOIN,
|
|
48
|
+
// so tests that care about the rejoin rate limiter don't bounce to FAST
|
|
49
|
+
// based on wall-clock timing. Individual tests that want to exercise the
|
|
50
|
+
// FAST branch reset this to a high value.
|
|
51
|
+
(
|
|
52
|
+
call as unknown as { fastReconnectDeadlineSeconds: number }
|
|
53
|
+
).fastReconnectDeadlineSeconds = -1;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('Call reconnect stopping conditions', () => {
|
|
57
|
+
let call: Call;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
call = makeCall();
|
|
61
|
+
// make sleep instant so the loop flushes quickly
|
|
62
|
+
vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
|
|
63
|
+
// stub leave so the terminal path doesn't attempt real teardown
|
|
64
|
+
vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
65
|
+
// avoid the `get()` call inside markAsReconnectingFailed hitting the network
|
|
66
|
+
vi.spyOn(call, 'get').mockResolvedValue({} as never);
|
|
67
|
+
// default-stub all three strategy implementations to reject. Individual
|
|
68
|
+
// tests override these with mockResolvedValue / mockImplementation as
|
|
69
|
+
// needed. This keeps the reconnect loop from reaching the real network.
|
|
70
|
+
vi.spyOn(
|
|
71
|
+
call as unknown as { reconnectFast: () => Promise<void> },
|
|
72
|
+
'reconnectFast',
|
|
73
|
+
).mockRejectedValue(new Error('fast stub'));
|
|
74
|
+
vi.spyOn(
|
|
75
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
76
|
+
'reconnectRejoin',
|
|
77
|
+
).mockRejectedValue(new Error('rejoin stub'));
|
|
78
|
+
vi.spyOn(
|
|
79
|
+
call as unknown as { reconnectMigrate: () => Promise<void> },
|
|
80
|
+
'reconnectMigrate',
|
|
81
|
+
).mockRejectedValue(new Error('migrate stub'));
|
|
82
|
+
primeForReconnect(call);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('rejoin rate limit', () => {
|
|
90
|
+
it('triggers leave with rejoin_attempt_limit_exceeded after the budget is exhausted', async () => {
|
|
91
|
+
// tight cap for speed
|
|
92
|
+
call.setRejoinAttemptLimit(3, 60);
|
|
93
|
+
// every rejoin attempt fails so the loop stays in REJOIN
|
|
94
|
+
const rejoinSpy = vi
|
|
95
|
+
.spyOn(
|
|
96
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
97
|
+
'reconnectRejoin',
|
|
98
|
+
)
|
|
99
|
+
.mockRejectedValue(new Error('rejoin failed'));
|
|
100
|
+
|
|
101
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
102
|
+
|
|
103
|
+
// budget = 3 → 3 registered attempts, 4th denied and triggers leave
|
|
104
|
+
expect(rejoinSpy).toHaveBeenCalledTimes(3);
|
|
105
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
106
|
+
message: 'rejoin_attempt_limit_exceeded',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does NOT trigger leave while under the rejoin budget', async () => {
|
|
111
|
+
call.setRejoinAttemptLimit(10, 60);
|
|
112
|
+
const rejoinSpy = vi
|
|
113
|
+
.spyOn(
|
|
114
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
115
|
+
'reconnectRejoin',
|
|
116
|
+
)
|
|
117
|
+
.mockImplementationOnce(async () => {
|
|
118
|
+
// first call succeeds — loop exits
|
|
119
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
123
|
+
|
|
124
|
+
expect(rejoinSpy).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('FAST strategy is NOT counted against the rejoin budget', async () => {
|
|
129
|
+
call.setRejoinAttemptLimit(2, 60);
|
|
130
|
+
// stub FAST so it "succeeds" each time (we re-enter by resetting state)
|
|
131
|
+
vi.spyOn(
|
|
132
|
+
call as unknown as { reconnectFast: () => Promise<void> },
|
|
133
|
+
'reconnectFast',
|
|
134
|
+
).mockImplementation(async () => {
|
|
135
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
for (let i = 0; i < 5; i++) {
|
|
139
|
+
call.state.setCallingState(CallingState.JOINING);
|
|
140
|
+
await call['reconnect'](WebsocketReconnectStrategy.FAST, 'test');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// the rejoin rate limiter should have no recorded attempts —
|
|
144
|
+
// FAST never registers an attempt, and because each FAST here
|
|
145
|
+
// "succeeds" on the first iteration, no REJOIN fallback kicks in
|
|
146
|
+
expect(call['rejoinRateLimiter']['timestamps']).toHaveLength(0);
|
|
147
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('retryInterval backoff', () => {
|
|
152
|
+
it('invokes retryInterval(attempt) between failed iterations, not a fixed delay', async () => {
|
|
153
|
+
call.setRejoinAttemptLimit(3, 60);
|
|
154
|
+
const retryIntervalSpy = vi
|
|
155
|
+
.spyOn(connectionUtils, 'retryInterval')
|
|
156
|
+
.mockReturnValue(0);
|
|
157
|
+
vi.spyOn(
|
|
158
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
159
|
+
'reconnectRejoin',
|
|
160
|
+
).mockRejectedValue(new Error('rejoin failed'));
|
|
161
|
+
|
|
162
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
163
|
+
|
|
164
|
+
// 3 iterations → at least 3 backoff calls with increasing attempt index
|
|
165
|
+
const calls = retryIntervalSpy.mock.calls.map((c) => c[0]);
|
|
166
|
+
expect(calls.length).toBeGreaterThanOrEqual(3);
|
|
167
|
+
expect(calls[0]).toBe(0);
|
|
168
|
+
expect(calls[1]).toBe(1);
|
|
169
|
+
expect(calls[2]).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('unsupported-network detector', () => {
|
|
174
|
+
it('triggers leave with webrtc_unsupported_network after N ice_never_connected reasons', async () => {
|
|
175
|
+
call.setMaxIceFailuresWithoutConnect(2);
|
|
176
|
+
// reconnect no-ops (REJOIN not even attempted in this test because we
|
|
177
|
+
// bail out at the threshold check before the loop)
|
|
178
|
+
vi.spyOn(
|
|
179
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
180
|
+
'reconnectRejoin',
|
|
181
|
+
).mockImplementation(async () => {
|
|
182
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// first ice_never_connected: counter = 1, still under threshold (2)
|
|
186
|
+
await call['reconnect'](
|
|
187
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
188
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
189
|
+
);
|
|
190
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
191
|
+
|
|
192
|
+
// After a successful SFU join (no ICE-connected event yet), the
|
|
193
|
+
// counter must NOT be reset — the reset only happens once a peer
|
|
194
|
+
// connection actually reaches `connected`/`completed` end-to-end.
|
|
195
|
+
primeForReconnect(call);
|
|
196
|
+
await call['reconnect'](
|
|
197
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
198
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
199
|
+
);
|
|
200
|
+
// counter now 2 → threshold met → leave
|
|
201
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
202
|
+
message: 'webrtc_unsupported_network',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('does NOT trigger leave when the reason is NOT ice_never_connected', async () => {
|
|
207
|
+
call.setMaxIceFailuresWithoutConnect(1);
|
|
208
|
+
vi.spyOn(
|
|
209
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
210
|
+
'reconnectRejoin',
|
|
211
|
+
).mockImplementation(async () => {
|
|
212
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await call['reconnect'](
|
|
216
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
217
|
+
'some_other_reason',
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('consecutive negotiation failures', () => {
|
|
225
|
+
const makeNegotiationError = () =>
|
|
226
|
+
new NegotiationError({
|
|
227
|
+
code: ErrorCode.PARTICIPANT_NOT_FOUND,
|
|
228
|
+
message: 'test',
|
|
229
|
+
shouldRetry: true,
|
|
230
|
+
} as never);
|
|
231
|
+
|
|
232
|
+
it('triggers leave with repeated_negotiation_failures after the streak threshold', async () => {
|
|
233
|
+
call.setMaxConsecutiveNegotiationFailures(3);
|
|
234
|
+
call.setRejoinAttemptLimit(100, 60); // keep rejoin cap out of the way
|
|
235
|
+
vi.spyOn(
|
|
236
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
237
|
+
'reconnectRejoin',
|
|
238
|
+
).mockRejectedValue(makeNegotiationError());
|
|
239
|
+
|
|
240
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
241
|
+
|
|
242
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
243
|
+
message: 'repeated_negotiation_failures',
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('resets the streak counter on a successful iteration', async () => {
|
|
248
|
+
call.setMaxConsecutiveNegotiationFailures(3);
|
|
249
|
+
call.setRejoinAttemptLimit(100, 60);
|
|
250
|
+
let calls = 0;
|
|
251
|
+
vi.spyOn(
|
|
252
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
253
|
+
'reconnectRejoin',
|
|
254
|
+
).mockImplementation(async () => {
|
|
255
|
+
calls++;
|
|
256
|
+
if (calls <= 2) throw makeNegotiationError();
|
|
257
|
+
if (calls === 3) {
|
|
258
|
+
// success on the 3rd attempt — resets the streak
|
|
259
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
265
|
+
|
|
266
|
+
expect(calls).toBe(3);
|
|
267
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
268
|
+
expect(call['consecutiveNegotiationFailures']).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('counter reset semantics', () => {
|
|
273
|
+
it('rejoinRateLimiter does NOT reset on a successful SFU reconnect — the rolling window persists', async () => {
|
|
274
|
+
call.setRejoinAttemptLimit(3, 60);
|
|
275
|
+
// Each REJOIN succeeds on the first iteration; without the bad reset,
|
|
276
|
+
// the rolling window accumulates timestamps across successful cycles.
|
|
277
|
+
vi.spyOn(
|
|
278
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
279
|
+
'reconnectRejoin',
|
|
280
|
+
).mockImplementation(async () => {
|
|
281
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
for (let i = 0; i < 3; i++) {
|
|
285
|
+
primeForReconnect(call);
|
|
286
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
287
|
+
}
|
|
288
|
+
expect(call['rejoinRateLimiter']['timestamps']).toHaveLength(3);
|
|
289
|
+
|
|
290
|
+
// 4th attempt is over the budget and triggers leave
|
|
291
|
+
primeForReconnect(call);
|
|
292
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
293
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
294
|
+
message: 'rejoin_attempt_limit_exceeded',
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('iceFailuresWithoutConnect does NOT reset on a successful SFU reconnect', async () => {
|
|
299
|
+
call.setMaxIceFailuresWithoutConnect(3);
|
|
300
|
+
// pre-load the counter as if a previous PC had failed before connecting
|
|
301
|
+
call['iceFailuresWithoutConnect'] = 2;
|
|
302
|
+
|
|
303
|
+
vi.spyOn(
|
|
304
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
305
|
+
'reconnectRejoin',
|
|
306
|
+
).mockImplementation(async () => {
|
|
307
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
308
|
+
});
|
|
309
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
310
|
+
|
|
311
|
+
// Successful SFU join — but ICE never connected; counter must persist.
|
|
312
|
+
expect(call['iceFailuresWithoutConnect']).toBe(2);
|
|
313
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('consecutiveNegotiationFailures DOES reset on a successful reconnect iteration', async () => {
|
|
317
|
+
call['consecutiveNegotiationFailures'] = 2;
|
|
318
|
+
vi.spyOn(
|
|
319
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
320
|
+
'reconnectRejoin',
|
|
321
|
+
).mockImplementation(async () => {
|
|
322
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
326
|
+
|
|
327
|
+
expect(call['consecutiveNegotiationFailures']).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('tunability setters', () => {
|
|
332
|
+
it('setRejoinAttemptLimit changes the budget in place', async () => {
|
|
333
|
+
call.setRejoinAttemptLimit(1, 60);
|
|
334
|
+
vi.spyOn(
|
|
335
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
336
|
+
'reconnectRejoin',
|
|
337
|
+
).mockRejectedValue(new Error('rejoin failed'));
|
|
338
|
+
|
|
339
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
340
|
+
|
|
341
|
+
// budget=1 → one attempt, then leave
|
|
342
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
343
|
+
message: 'rejoin_attempt_limit_exceeded',
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('setMaxIceFailuresWithoutConnect=1 trips on the first ice_never_connected', async () => {
|
|
348
|
+
call.setMaxIceFailuresWithoutConnect(1);
|
|
349
|
+
|
|
350
|
+
await call['reconnect'](
|
|
351
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
352
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
356
|
+
message: 'webrtc_unsupported_network',
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('setMaxConsecutiveNegotiationFailures=1 trips on the first NegotiationError', async () => {
|
|
361
|
+
call.setMaxConsecutiveNegotiationFailures(1);
|
|
362
|
+
call.setRejoinAttemptLimit(100, 60);
|
|
363
|
+
const err = new NegotiationError({
|
|
364
|
+
code: ErrorCode.PARTICIPANT_NOT_FOUND,
|
|
365
|
+
message: 'x',
|
|
366
|
+
shouldRetry: true,
|
|
367
|
+
} as never);
|
|
368
|
+
vi.spyOn(
|
|
369
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
370
|
+
'reconnectRejoin',
|
|
371
|
+
).mockRejectedValue(err);
|
|
372
|
+
|
|
373
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
374
|
+
|
|
375
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
376
|
+
message: 'repeated_negotiation_failures',
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('clamps zero/negative inputs to a floor of 1', () => {
|
|
381
|
+
// Without clamping, n=0 would trip immediately on the first event
|
|
382
|
+
// (1 >= 0 is true), turning the setter into an instant kill switch.
|
|
383
|
+
call.setMaxIceFailuresWithoutConnect(0);
|
|
384
|
+
expect(
|
|
385
|
+
(call as unknown as { maxIceFailuresWithoutConnect: number })
|
|
386
|
+
.maxIceFailuresWithoutConnect,
|
|
387
|
+
).toBe(1);
|
|
388
|
+
|
|
389
|
+
call.setMaxConsecutiveNegotiationFailures(-5);
|
|
390
|
+
expect(
|
|
391
|
+
(call as unknown as { maxConsecutiveNegotiationFailures: number })
|
|
392
|
+
.maxConsecutiveNegotiationFailures,
|
|
393
|
+
).toBe(1);
|
|
394
|
+
|
|
395
|
+
call.setRejoinAttemptLimit(0, 0);
|
|
396
|
+
const limiter = (
|
|
397
|
+
call as unknown as {
|
|
398
|
+
rejoinRateLimiter: { maxAttempts: number; windowMs: number };
|
|
399
|
+
}
|
|
400
|
+
).rejoinRateLimiter;
|
|
401
|
+
expect(limiter.maxAttempts).toBe(1);
|
|
402
|
+
expect(limiter.windowMs).toBe(1000);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* End-to-end-ish wiring tests: simulate failures at the peer-connection layer
|
|
409
|
+
* and verify they propagate through `onReconnectionNeeded` → `Call.reconnect` →
|
|
410
|
+
* counters → `leave({ message })`. This covers the gap between the unit tests
|
|
411
|
+
* above (which call `Call.reconnect` directly) and a true browser harness.
|
|
412
|
+
*/
|
|
413
|
+
describe('Call reconnect wiring (PC event → leave)', () => {
|
|
414
|
+
let call: Call;
|
|
415
|
+
let sfuClient: StreamSfuClient;
|
|
416
|
+
let dispatcher: Dispatcher;
|
|
417
|
+
|
|
418
|
+
/** Builds a Publisher wired to forward onReconnectionNeeded + onIceConnected to Call. */
|
|
419
|
+
const makePublisherWiredToCall = () => {
|
|
420
|
+
const publisher = new Publisher(
|
|
421
|
+
{
|
|
422
|
+
sfuClient,
|
|
423
|
+
dispatcher,
|
|
424
|
+
state: call.state,
|
|
425
|
+
tag: 'test',
|
|
426
|
+
enableTracing: false,
|
|
427
|
+
onReconnectionNeeded: (kind, reason) => {
|
|
428
|
+
// mirror Call.ts:1409 wiring
|
|
429
|
+
call['reconnect'](kind, reason).catch(() => {});
|
|
430
|
+
},
|
|
431
|
+
onIceConnected: () => {
|
|
432
|
+
// mirror Call.ts:1416 wiring
|
|
433
|
+
call['iceFailuresWithoutConnect'] = 0;
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
[],
|
|
437
|
+
);
|
|
438
|
+
return publisher;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
beforeEach(() => {
|
|
442
|
+
dispatcher = new Dispatcher();
|
|
443
|
+
sfuClient = new StreamSfuClient({
|
|
444
|
+
dispatcher,
|
|
445
|
+
sessionId: 'session-id-test',
|
|
446
|
+
streamClient: new StreamClient('abc'),
|
|
447
|
+
cid: 'test:123',
|
|
448
|
+
credentials: {
|
|
449
|
+
server: {
|
|
450
|
+
url: 'https://getstream.io/',
|
|
451
|
+
ws_endpoint: 'https://getstream.io/ws',
|
|
452
|
+
edge_name: 'sfu-1',
|
|
453
|
+
},
|
|
454
|
+
token: 'token',
|
|
455
|
+
ice_servers: [],
|
|
456
|
+
},
|
|
457
|
+
tag: 'test',
|
|
458
|
+
enableTracing: false,
|
|
459
|
+
});
|
|
460
|
+
// @ts-expect-error readonly field
|
|
461
|
+
sfuClient.iceTrickleBuffer = new IceTrickleBuffer();
|
|
462
|
+
|
|
463
|
+
const streamClient = new StreamClient('test-key');
|
|
464
|
+
const clientStore = new StreamVideoWriteableStateStore();
|
|
465
|
+
call = new Call({
|
|
466
|
+
type: 'default',
|
|
467
|
+
id: 'test-call',
|
|
468
|
+
streamClient,
|
|
469
|
+
clientStore,
|
|
470
|
+
ringing: false,
|
|
471
|
+
watching: false,
|
|
472
|
+
});
|
|
473
|
+
primeForReconnect(call);
|
|
474
|
+
|
|
475
|
+
// make the Call.reconnect loop deterministic
|
|
476
|
+
vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
|
|
477
|
+
vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
478
|
+
vi.spyOn(call, 'get').mockResolvedValue({} as never);
|
|
479
|
+
vi.spyOn(
|
|
480
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
481
|
+
'reconnectRejoin',
|
|
482
|
+
).mockImplementation(async () => {
|
|
483
|
+
// each REJOIN attempt "succeeds" so the loop exits without bouncing
|
|
484
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
afterEach(() => {
|
|
489
|
+
vi.clearAllMocks();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Scenario 1 (manual smoke equivalent: 100% packet loss / blocked UDP):
|
|
494
|
+
* a publisher whose ICE never reaches `connected`/`completed` and goes to
|
|
495
|
+
* `failed` should make Call.reconnect count the reason. After
|
|
496
|
+
* `maxIceFailuresWithoutConnect` such failures, the call must `leave`.
|
|
497
|
+
*/
|
|
498
|
+
it('publisher ICE failed (never-connected) drives Call.reconnect → webrtc_unsupported_network', async () => {
|
|
499
|
+
call.setMaxIceFailuresWithoutConnect(2);
|
|
500
|
+
const publisher = makePublisherWiredToCall();
|
|
501
|
+
|
|
502
|
+
const triggerIceFailedNeverConnected = async () => {
|
|
503
|
+
// @ts-expect-error private field
|
|
504
|
+
publisher['pc'].iceConnectionState = 'failed';
|
|
505
|
+
publisher['onIceConnectionStateChange']();
|
|
506
|
+
// flush the microtask queue so the async reconnect runs
|
|
507
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
await triggerIceFailedNeverConnected();
|
|
511
|
+
expect(call['iceFailuresWithoutConnect']).toBe(1);
|
|
512
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
513
|
+
|
|
514
|
+
// re-arm the loop guard for the second pass
|
|
515
|
+
primeForReconnect(call);
|
|
516
|
+
await triggerIceFailedNeverConnected();
|
|
517
|
+
|
|
518
|
+
expect(call['iceFailuresWithoutConnect']).toBe(2);
|
|
519
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
520
|
+
message: 'webrtc_unsupported_network',
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Once ICE has reached `connected`, a subsequent `failed` is a normal
|
|
526
|
+
* recovery case — it should NOT count toward the unsupported-network
|
|
527
|
+
* threshold and should NOT cause leave.
|
|
528
|
+
*/
|
|
529
|
+
it('publisher ICE failed AFTER prior connected does NOT count toward unsupported_network', async () => {
|
|
530
|
+
call.setMaxIceFailuresWithoutConnect(1);
|
|
531
|
+
const publisher = makePublisherWiredToCall();
|
|
532
|
+
// restartIce is invoked in the regular path; stub it
|
|
533
|
+
vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
534
|
+
|
|
535
|
+
// simulate prior healthy ICE
|
|
536
|
+
// @ts-expect-error private field
|
|
537
|
+
publisher['pc'].iceConnectionState = 'connected';
|
|
538
|
+
publisher['onIceConnectionStateChange']();
|
|
539
|
+
|
|
540
|
+
// now ICE drops to failed
|
|
541
|
+
// @ts-expect-error private field
|
|
542
|
+
publisher['pc'].iceConnectionState = 'failed';
|
|
543
|
+
publisher['onIceConnectionStateChange']();
|
|
544
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
545
|
+
|
|
546
|
+
expect(call['iceFailuresWithoutConnect']).toBe(0);
|
|
547
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
548
|
+
// and a regular ICE restart was attempted
|
|
549
|
+
expect(publisher.restartIce).toHaveBeenCalled();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Scenario 4 (manual smoke equivalent: drop only the signal WS while the
|
|
554
|
+
* publisher PC stays `connected`): the FAST path should NOT call
|
|
555
|
+
* `publisher.restartIce()` because the PC is stable.
|
|
556
|
+
*/
|
|
557
|
+
it('FAST path skips publisher.restartIce when publisher PC is stable', async () => {
|
|
558
|
+
const publisher = makePublisherWiredToCall();
|
|
559
|
+
// @ts-expect-error private field
|
|
560
|
+
publisher['pc'].iceConnectionState = 'connected';
|
|
561
|
+
publisher['onIceConnectionStateChange']();
|
|
562
|
+
// @ts-expect-error private field
|
|
563
|
+
publisher['pc'].connectionState = 'connected';
|
|
564
|
+
|
|
565
|
+
// pretend the publisher has tracks so isPublishing() would return true
|
|
566
|
+
vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
|
|
567
|
+
const restartIceSpy = vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
568
|
+
const setSfuSpy = vi.spyOn(publisher, 'setSfuClient');
|
|
569
|
+
call['publisher'] = publisher;
|
|
570
|
+
|
|
571
|
+
// mimic the FAST branch in doJoin: restoreICE is the gateway
|
|
572
|
+
const publisherIsStable = call['publisher']?.isStable() ?? true;
|
|
573
|
+
const includePublisher =
|
|
574
|
+
!!call['publisher']?.isPublishing() && !publisherIsStable;
|
|
575
|
+
await call['restoreICE'](sfuClient, {
|
|
576
|
+
includeSubscriber: false,
|
|
577
|
+
includePublisher,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
expect(includePublisher).toBe(false);
|
|
581
|
+
expect(setSfuSpy).toHaveBeenCalledWith(sfuClient); // wire still updated
|
|
582
|
+
expect(restartIceSpy).not.toHaveBeenCalled(); // but NO ICE restart
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Counterpart to the above: when the publisher PC is NOT stable (e.g.,
|
|
587
|
+
* `disconnected`), the FAST path SHOULD still issue an ICE restart.
|
|
588
|
+
*/
|
|
589
|
+
it('FAST path DOES call publisher.restartIce when publisher PC is unstable', async () => {
|
|
590
|
+
const publisher = makePublisherWiredToCall();
|
|
591
|
+
// @ts-expect-error private field
|
|
592
|
+
publisher['pc'].iceConnectionState = 'connected';
|
|
593
|
+
publisher['onIceConnectionStateChange']();
|
|
594
|
+
// @ts-expect-error private field
|
|
595
|
+
publisher['pc'].iceConnectionState = 'disconnected';
|
|
596
|
+
// @ts-expect-error private field
|
|
597
|
+
publisher['pc'].connectionState = 'connected';
|
|
598
|
+
|
|
599
|
+
vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
|
|
600
|
+
const restartIceSpy = vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
601
|
+
call['publisher'] = publisher;
|
|
602
|
+
|
|
603
|
+
const publisherIsStable = call['publisher']?.isStable() ?? true;
|
|
604
|
+
const includePublisher =
|
|
605
|
+
!!call['publisher']?.isPublishing() && !publisherIsStable;
|
|
606
|
+
await call['restoreICE'](sfuClient, {
|
|
607
|
+
includeSubscriber: false,
|
|
608
|
+
includePublisher,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
expect(includePublisher).toBe(true);
|
|
612
|
+
expect(restartIceSpy).toHaveBeenCalled();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Counter reset semantics — the fix from the Codex adversarial review:
|
|
617
|
+
* `iceFailuresWithoutConnect` must persist across SFU joins; only an
|
|
618
|
+
* actual ICE-connected event clears it.
|
|
619
|
+
*/
|
|
620
|
+
it('iceFailuresWithoutConnect resets when the publisher PC reaches connected', () => {
|
|
621
|
+
call['iceFailuresWithoutConnect'] = 2;
|
|
622
|
+
const publisher = makePublisherWiredToCall();
|
|
623
|
+
|
|
624
|
+
// simulate ICE reaching `connected` end-to-end on the publisher
|
|
625
|
+
// @ts-expect-error private field
|
|
626
|
+
publisher['pc'].iceConnectionState = 'connected';
|
|
627
|
+
publisher['onIceConnectionStateChange']();
|
|
628
|
+
|
|
629
|
+
expect(call['iceFailuresWithoutConnect']).toBe(0);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('onIceConnected fires exactly once per peer-connection lifetime', () => {
|
|
633
|
+
let calls = 0;
|
|
634
|
+
const publisher = new Publisher(
|
|
635
|
+
{
|
|
636
|
+
sfuClient,
|
|
637
|
+
dispatcher,
|
|
638
|
+
state: call.state,
|
|
639
|
+
tag: 'test',
|
|
640
|
+
enableTracing: false,
|
|
641
|
+
onReconnectionNeeded: () => {},
|
|
642
|
+
onIceConnected: () => {
|
|
643
|
+
calls++;
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
[],
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
// connected → counts
|
|
650
|
+
// @ts-expect-error private field
|
|
651
|
+
publisher['pc'].iceConnectionState = 'connected';
|
|
652
|
+
publisher['onIceConnectionStateChange']();
|
|
653
|
+
expect(calls).toBe(1);
|
|
654
|
+
|
|
655
|
+
// disconnected → connected → does NOT fire again
|
|
656
|
+
// @ts-expect-error private field
|
|
657
|
+
publisher['pc'].iceConnectionState = 'disconnected';
|
|
658
|
+
publisher['onIceConnectionStateChange']();
|
|
659
|
+
// @ts-expect-error private field
|
|
660
|
+
publisher['pc'].iceConnectionState = 'connected';
|
|
661
|
+
publisher['onIceConnectionStateChange']();
|
|
662
|
+
expect(calls).toBe(1);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Cross-peer count: a publisher AND a subscriber both failing without ever
|
|
667
|
+
* connecting should add up to the same `iceFailuresWithoutConnect` budget.
|
|
668
|
+
*/
|
|
669
|
+
it('publisher + subscriber failures share the same unsupported_network budget', async () => {
|
|
670
|
+
call.setMaxIceFailuresWithoutConnect(2);
|
|
671
|
+
const publisher = makePublisherWiredToCall();
|
|
672
|
+
const subscriber = new Subscriber({
|
|
673
|
+
sfuClient,
|
|
674
|
+
dispatcher,
|
|
675
|
+
state: call.state,
|
|
676
|
+
tag: 'test',
|
|
677
|
+
enableTracing: false,
|
|
678
|
+
onReconnectionNeeded: (kind, reason) => {
|
|
679
|
+
call['reconnect'](kind, reason).catch(() => {});
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// first failure on the publisher
|
|
684
|
+
// @ts-expect-error private field
|
|
685
|
+
publisher['pc'].iceConnectionState = 'failed';
|
|
686
|
+
publisher['onIceConnectionStateChange']();
|
|
687
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
688
|
+
expect(call['iceFailuresWithoutConnect']).toBe(1);
|
|
689
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
690
|
+
|
|
691
|
+
primeForReconnect(call);
|
|
692
|
+
|
|
693
|
+
// second failure on the subscriber — same shared counter, trips the limit
|
|
694
|
+
// @ts-expect-error private field
|
|
695
|
+
subscriber['pc'].iceConnectionState = 'failed';
|
|
696
|
+
subscriber['onIceConnectionStateChange']();
|
|
697
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
698
|
+
|
|
699
|
+
expect(call['iceFailuresWithoutConnect']).toBe(2);
|
|
700
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
701
|
+
message: 'webrtc_unsupported_network',
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* The peerType passed by Subscriber should be `SUBSCRIBER`. (Sanity check
|
|
707
|
+
* of the wiring contract.)
|
|
708
|
+
*/
|
|
709
|
+
it('subscriber emits onReconnectionNeeded with PeerType.SUBSCRIBER', () => {
|
|
710
|
+
const onReconnectionNeeded = vi.fn();
|
|
711
|
+
const subscriber = new Subscriber({
|
|
712
|
+
sfuClient,
|
|
713
|
+
dispatcher,
|
|
714
|
+
state: call.state,
|
|
715
|
+
tag: 'test',
|
|
716
|
+
enableTracing: false,
|
|
717
|
+
onReconnectionNeeded,
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// @ts-expect-error private field
|
|
721
|
+
subscriber['pc'].iceConnectionState = 'failed';
|
|
722
|
+
subscriber['onIceConnectionStateChange']();
|
|
723
|
+
|
|
724
|
+
expect(onReconnectionNeeded).toHaveBeenCalledWith(
|
|
725
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
726
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
727
|
+
PeerType.SUBSCRIBER,
|
|
728
|
+
);
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* `leave()` runs after both the success path (end of `joinFlow`) and the
|
|
734
|
+
* giveUpAndLeave path. Only the success path resets `reconnectStrategy` /
|
|
735
|
+
* `reconnectReason`. Without resetting them in `leave()` itself, a Call
|
|
736
|
+
* instance reused after a failed-reconnect terminal leave would still see
|
|
737
|
+
* `reconnectStrategy != UNSPECIFIED` on the next `join()` and would send
|
|
738
|
+
* a stale `ReconnectDetails` to the SFU.
|
|
739
|
+
*/
|
|
740
|
+
describe('Call.leave() reconnect-state reset', () => {
|
|
741
|
+
it('clears reconnectStrategy, reconnectReason, and reconnectAttempts', async () => {
|
|
742
|
+
const call = makeCall();
|
|
743
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
744
|
+
|
|
745
|
+
call['reconnectStrategy'] = WebsocketReconnectStrategy.REJOIN;
|
|
746
|
+
call['reconnectReason'] = ReconnectReason.ICE_NEVER_CONNECTED;
|
|
747
|
+
call['reconnectAttempts'] = 3;
|
|
748
|
+
call['iceFailuresWithoutConnect'] = 2;
|
|
749
|
+
call['consecutiveNegotiationFailures'] = 1;
|
|
750
|
+
|
|
751
|
+
await call.leave();
|
|
752
|
+
|
|
753
|
+
expect(call['reconnectStrategy']).toBe(
|
|
754
|
+
WebsocketReconnectStrategy.UNSPECIFIED,
|
|
755
|
+
);
|
|
756
|
+
expect(call['reconnectReason']).toBe('');
|
|
757
|
+
expect(call['reconnectAttempts']).toBe(0);
|
|
758
|
+
expect(call['iceFailuresWithoutConnect']).toBe(0);
|
|
759
|
+
expect(call['consecutiveNegotiationFailures']).toBe(0);
|
|
760
|
+
});
|
|
761
|
+
});
|