@stream-io/video-client 2.0.0-beta.0 → 2.0.0-beta.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.
- package/CHANGELOG.md +18 -0
- package/dist/index.browser.es.js +1126 -791
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1126 -791
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1126 -791
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +37 -11
- package/dist/src/StreamVideoClient.d.ts +15 -21
- package/dist/src/coordinator/connection/types.d.ts +16 -0
- package/dist/src/events/call.d.ts +0 -11
- package/dist/src/gen/coordinator/index.d.ts +73 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
- package/dist/src/ringing/RingStatePoller.d.ts +46 -0
- package/dist/src/ringing/RingTimeout.d.ts +27 -0
- package/dist/src/ringing/index.d.ts +4 -0
- package/dist/src/ringing/reconcileRingState.d.ts +18 -0
- package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
- 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/stats/utils.d.ts +6 -0
- package/dist/src/store/CallState.d.ts +118 -267
- package/dist/src/store/ClientState.d.ts +62 -0
- package/dist/src/store/index.d.ts +1 -1
- package/dist/src/store/subjects.d.ts +15 -0
- package/dist/src/store/types.d.ts +22 -0
- package/dist/src/types.d.ts +33 -4
- package/index.ts +1 -0
- package/package.json +2 -2
- package/src/Call.ts +285 -125
- package/src/StreamSfuClient.ts +6 -2
- package/src/StreamVideoClient.ts +40 -47
- package/src/__tests__/Call.autodrop.test.ts +118 -66
- package/src/__tests__/Call.getRingState.test.ts +87 -0
- package/src/__tests__/Call.lifecycle.test.ts +499 -6
- package/src/__tests__/Call.publishing.test.ts +2 -2
- package/src/__tests__/Call.ringSettled.test.ts +71 -0
- package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
- package/src/__tests__/StreamSfuClient.test.ts +48 -0
- package/src/__tests__/StreamVideoClient.disconnect.test.ts +62 -0
- package/src/coordinator/connection/types.ts +19 -0
- package/src/devices/__tests__/CameraManager.test.ts +2 -2
- package/src/devices/__tests__/DeviceManager.test.ts +2 -2
- package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -2
- package/src/devices/__tests__/MicrophoneManager.test.ts +2 -2
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -2
- package/src/devices/__tests__/ScreenShareManager.test.ts +3 -3
- package/src/devices/__tests__/SpeakerManager.test.ts +4 -4
- package/src/events/__tests__/call.test.ts +5 -220
- package/src/events/call.ts +1 -73
- package/src/events/callEventHandlers.ts +12 -23
- package/src/gen/coordinator/index.ts +67 -0
- package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +2 -2
- package/src/helpers/__tests__/DynascaleManager.test.ts +2 -2
- package/src/helpers/__tests__/ViewportTracker.test.ts +2 -2
- package/src/reporting/ClientEventReporter.ts +30 -4
- package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
- package/src/ringing/RingStatePoller.ts +177 -0
- package/src/ringing/RingTimeout.ts +90 -0
- package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
- package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
- package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
- package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
- package/src/ringing/index.ts +4 -0
- package/src/ringing/reconcileRingState.ts +108 -0
- package/src/ringing/resolveOwnRingOutcome.ts +58 -0
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Call.reconnect.test.ts +5 -5
- 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/stats/__tests__/utils.test.ts +33 -0
- package/src/stats/utils.ts +15 -0
- package/src/store/CallState.ts +305 -550
- package/src/store/ClientState.ts +101 -0
- package/src/store/__tests__/CallState.test.ts +60 -1
- package/src/store/__tests__/ClientState.test.ts +35 -0
- package/src/store/__tests__/rxUtils.test.ts +67 -1
- package/src/store/index.ts +1 -1
- package/src/store/rxUtils.ts +8 -4
- package/src/store/subjects.ts +30 -0
- package/src/store/types.ts +45 -0
- package/src/types.ts +40 -4
- package/dist/src/store/stateStore.d.ts +0 -84
- package/src/store/stateStore.ts +0 -156
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
5
|
+
import { Call } from '../../Call';
|
|
6
|
+
import { RingStatePoller } from '../RingStatePoller';
|
|
7
|
+
import { StreamClient } from '../../coordinator/connection/client';
|
|
8
|
+
import { ClientEventReporter } from '../../reporting';
|
|
9
|
+
import { generateUUIDv4 } from '../../coordinator/connection/utils';
|
|
10
|
+
import { CallingState, ClientState } from '../../store';
|
|
11
|
+
import {
|
|
12
|
+
ErrorFromResponse,
|
|
13
|
+
type StreamClientOptions,
|
|
14
|
+
} from '../../coordinator/connection/types';
|
|
15
|
+
import type { GetCallRingStateResponse } from '../../gen/coordinator';
|
|
16
|
+
|
|
17
|
+
const SESSION_ID = 'session-1';
|
|
18
|
+
const START_AFTER_MS = 15_000;
|
|
19
|
+
const INTERVAL_MS = 5_000;
|
|
20
|
+
|
|
21
|
+
const ringState = (
|
|
22
|
+
overrides: Partial<GetCallRingStateResponse> = {},
|
|
23
|
+
): GetCallRingStateResponse => ({
|
|
24
|
+
duration: '1ms',
|
|
25
|
+
call_cid: 'test:call',
|
|
26
|
+
session_id: SESSION_ID,
|
|
27
|
+
created_by_user_id: 'jane',
|
|
28
|
+
accepted_by: {},
|
|
29
|
+
rejected_by: {},
|
|
30
|
+
missed_by: {},
|
|
31
|
+
...overrides,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('RingStatePoller', () => {
|
|
35
|
+
const userId = 'jane';
|
|
36
|
+
let call: Call;
|
|
37
|
+
let poller: RingStatePoller;
|
|
38
|
+
|
|
39
|
+
const createCall = (options?: StreamClientOptions) => {
|
|
40
|
+
const clientState = new ClientState();
|
|
41
|
+
const streamClient = new StreamClient('abc', options);
|
|
42
|
+
const newCall = new Call({
|
|
43
|
+
type: 'test',
|
|
44
|
+
id: generateUUIDv4(),
|
|
45
|
+
streamClient,
|
|
46
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
47
|
+
clientState,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// @ts-expect-error mocking only what we need for the test
|
|
51
|
+
clientState.setConnectedUser({ id: userId });
|
|
52
|
+
// @ts-expect-error mocking only what we need for the test
|
|
53
|
+
newCall.state['createdBySubject'].next({ id: userId });
|
|
54
|
+
// @ts-expect-error mocking only what we need for the test
|
|
55
|
+
newCall.state['sessionSubject'].next({
|
|
56
|
+
id: SESSION_ID,
|
|
57
|
+
accepted_by: {},
|
|
58
|
+
rejected_by: {},
|
|
59
|
+
missed_by: {},
|
|
60
|
+
participants: [],
|
|
61
|
+
});
|
|
62
|
+
newCall.state['settingsSubject'].next({
|
|
63
|
+
ring: {
|
|
64
|
+
auto_cancel_timeout_ms: 30_000,
|
|
65
|
+
incoming_call_timeout_ms: 30_000,
|
|
66
|
+
missed_call_timeout_ms: 30_000,
|
|
67
|
+
},
|
|
68
|
+
// @ts-expect-error mocking only what we need for the test
|
|
69
|
+
screensharing: {
|
|
70
|
+
enabled: false,
|
|
71
|
+
target_resolution: { width: 100, height: 100 },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
newCall.state.setMembers(
|
|
75
|
+
fromPartial([{ user_id: userId }, { user_id: 'john' }]),
|
|
76
|
+
);
|
|
77
|
+
newCall.state['callingStateSubject'].next(CallingState.RINGING);
|
|
78
|
+
|
|
79
|
+
vi.spyOn(newCall, 'join').mockResolvedValue(undefined);
|
|
80
|
+
vi.spyOn(newCall, 'leave').mockResolvedValue(undefined);
|
|
81
|
+
|
|
82
|
+
return newCall;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const startPolling = (response = ringState()) => {
|
|
86
|
+
const getRingState = vi
|
|
87
|
+
.spyOn(call, 'getRingState')
|
|
88
|
+
.mockResolvedValue(response);
|
|
89
|
+
poller = new RingStatePoller(call);
|
|
90
|
+
poller.start();
|
|
91
|
+
return getRingState;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.useFakeTimers();
|
|
96
|
+
call = createCall();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
poller?.stop();
|
|
101
|
+
vi.useRealTimers();
|
|
102
|
+
vi.restoreAllMocks();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not poll before the quiet period elapses', async () => {
|
|
106
|
+
const getRingState = startPolling();
|
|
107
|
+
|
|
108
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS - 1);
|
|
109
|
+
expect(getRingState).not.toHaveBeenCalled();
|
|
110
|
+
|
|
111
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
112
|
+
expect(getRingState).toHaveBeenCalledWith(SESSION_ID);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps polling on the configured interval while the ring is pending', async () => {
|
|
116
|
+
const getRingState = startPolling();
|
|
117
|
+
|
|
118
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
119
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
120
|
+
|
|
121
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_MS);
|
|
122
|
+
expect(getRingState).toHaveBeenCalledTimes(2);
|
|
123
|
+
|
|
124
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_MS);
|
|
125
|
+
expect(getRingState).toHaveBeenCalledTimes(3);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('joins the call once when someone else accepted', async () => {
|
|
129
|
+
const getRingState = startPolling(
|
|
130
|
+
ringState({ accepted_by: { john: '2026-08-24T10:00:04Z' } }),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS + 3 * INTERVAL_MS);
|
|
134
|
+
|
|
135
|
+
expect(call.join).toHaveBeenCalledTimes(1);
|
|
136
|
+
// pins this as the poller's call site, not the WebSocket handlers'
|
|
137
|
+
expect(call.join).toHaveBeenCalledWith({ joinSource: 'ring-poll-api' });
|
|
138
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('ignores an acceptance by the current user', async () => {
|
|
142
|
+
startPolling(
|
|
143
|
+
ringState({ accepted_by: { [userId]: '2026-08-24T10:00:04Z' } }),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
147
|
+
|
|
148
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('cancels the call when everyone else rejected', async () => {
|
|
152
|
+
startPolling(ringState({ rejected_by: { john: '2026-08-24T10:00:09Z' } }));
|
|
153
|
+
|
|
154
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
155
|
+
|
|
156
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
157
|
+
reject: true,
|
|
158
|
+
reason: 'cancel',
|
|
159
|
+
message: 'ring: everyone rejected',
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('keeps the ring deadline across a pause', async () => {
|
|
164
|
+
startPolling();
|
|
165
|
+
const deadlineAt = poller['deadlineAt'];
|
|
166
|
+
|
|
167
|
+
poller.pause();
|
|
168
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
169
|
+
poller.resume();
|
|
170
|
+
|
|
171
|
+
expect(poller['deadlineAt']).toBe(deadlineAt);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('resumes straight back into polling when it was already past the quiet period', async () => {
|
|
175
|
+
const getRingState = startPolling();
|
|
176
|
+
|
|
177
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
178
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
179
|
+
|
|
180
|
+
poller.pause();
|
|
181
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_MS);
|
|
182
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
183
|
+
|
|
184
|
+
// no second quiet period: the next poll lands on the interval
|
|
185
|
+
poller.resume();
|
|
186
|
+
expect(getRingState).toHaveBeenCalledTimes(2);
|
|
187
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_MS);
|
|
188
|
+
expect(getRingState).toHaveBeenCalledTimes(3);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('stops on the first tick after the ring window, even across a pause', async () => {
|
|
192
|
+
const getRingState = startPolling();
|
|
193
|
+
|
|
194
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
195
|
+
poller.pause();
|
|
196
|
+
// the pause outlasts the ring window
|
|
197
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
198
|
+
poller.resume();
|
|
199
|
+
|
|
200
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
201
|
+
expect(poller['stopped']).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does not resume a stopped poller', async () => {
|
|
205
|
+
const getRingState = startPolling();
|
|
206
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
207
|
+
// paused mid-poll, so resuming would take the interval branch
|
|
208
|
+
poller.pause();
|
|
209
|
+
poller.stop();
|
|
210
|
+
|
|
211
|
+
poller.resume();
|
|
212
|
+
|
|
213
|
+
// no timer left behind on a stopped poller
|
|
214
|
+
expect(poller['intervalId']).toBeUndefined();
|
|
215
|
+
expect(poller['idleTimeoutId']).toBeUndefined();
|
|
216
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS + INTERVAL_MS);
|
|
217
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('keeps polling when everyone else is marked missed before auto-cancel', async () => {
|
|
221
|
+
startPolling(ringState({ missed_by: { john: '2026-08-24T10:00:35Z' } }));
|
|
222
|
+
|
|
223
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
224
|
+
|
|
225
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
226
|
+
expect(poller['stopped']).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('leaves without rejecting when the call has ended, even if it was accepted', async () => {
|
|
230
|
+
startPolling(
|
|
231
|
+
ringState({
|
|
232
|
+
accepted_by: { john: '2026-08-24T10:00:04Z' },
|
|
233
|
+
call_ended_at: '2026-08-24T10:00:20Z',
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
238
|
+
|
|
239
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
240
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
241
|
+
reject: false,
|
|
242
|
+
reason: 'ended',
|
|
243
|
+
message: 'ring: call ended',
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('restarts the quiet period when a ring event arrives over the WebSocket', async () => {
|
|
248
|
+
const getRingState = startPolling();
|
|
249
|
+
|
|
250
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS - 1_000);
|
|
251
|
+
call['streamClient'].dispatchEvent({
|
|
252
|
+
type: 'call.rejected',
|
|
253
|
+
call_cid: call.cid,
|
|
254
|
+
// @ts-expect-error mocking only what we need for the test
|
|
255
|
+
user: { id: 'john' },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
259
|
+
expect(getRingState).not.toHaveBeenCalled();
|
|
260
|
+
|
|
261
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS - 1_000);
|
|
262
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('stops polling once the call has left the ringing state', async () => {
|
|
266
|
+
const getRingState = startPolling();
|
|
267
|
+
|
|
268
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
269
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
270
|
+
|
|
271
|
+
call.state['callingStateSubject'].next(CallingState.JOINED);
|
|
272
|
+
await vi.advanceTimersByTimeAsync(3 * INTERVAL_MS);
|
|
273
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('stops polling as soon as the caller joins, without waiting for a tick', async () => {
|
|
277
|
+
const getRingState = startPolling();
|
|
278
|
+
|
|
279
|
+
// the caller joining an accepted call never goes through `leave`
|
|
280
|
+
call.state['callingStateSubject'].next(CallingState.JOINED);
|
|
281
|
+
expect(poller['stopped']).toBe(true);
|
|
282
|
+
|
|
283
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS + 3 * INTERVAL_MS);
|
|
284
|
+
expect(getRingState).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// `doJoin` restores the ringing state when a join fails, so JOINING is
|
|
288
|
+
// transient: giving up on it would abandon the ring the poller exists for.
|
|
289
|
+
it('keeps polling when a join attempt failed and left the call ringing', async () => {
|
|
290
|
+
const getRingState = startPolling();
|
|
291
|
+
|
|
292
|
+
call.state['callingStateSubject'].next(CallingState.JOINING);
|
|
293
|
+
expect(poller['stopped']).toBe(false);
|
|
294
|
+
call.state['callingStateSubject'].next(CallingState.RINGING);
|
|
295
|
+
|
|
296
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
297
|
+
expect(getRingState).toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('retries the join on the next poll when it failed', async () => {
|
|
301
|
+
const getRingState = startPolling(
|
|
302
|
+
ringState({ accepted_by: { john: '2026-08-24T10:00:04Z' } }),
|
|
303
|
+
);
|
|
304
|
+
vi.mocked(call.join).mockRejectedValueOnce(new Error('transient'));
|
|
305
|
+
|
|
306
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
307
|
+
expect(call.join).toHaveBeenCalledTimes(1);
|
|
308
|
+
expect(poller['stopped']).toBe(false);
|
|
309
|
+
|
|
310
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_MS);
|
|
311
|
+
expect(call.join).toHaveBeenCalledTimes(2);
|
|
312
|
+
expect(getRingState).toHaveBeenCalledTimes(2);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// `Call.join({ ring: true })` arms the poller while the call is already
|
|
316
|
+
// JOINING, so `start` has to refuse rather than arm and stop on the next tick.
|
|
317
|
+
it('does not arm when the call is not ringing', async () => {
|
|
318
|
+
call.state['callingStateSubject'].next(CallingState.JOINING);
|
|
319
|
+
const getRingState = startPolling();
|
|
320
|
+
|
|
321
|
+
expect(poller['sessionId']).toBeUndefined();
|
|
322
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS + INTERVAL_MS);
|
|
323
|
+
expect(getRingState).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('stops polling once the ring window has closed', async () => {
|
|
327
|
+
const getRingState = startPolling();
|
|
328
|
+
|
|
329
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
330
|
+
const callsWithinWindow = getRingState.mock.calls.length;
|
|
331
|
+
expect(callsWithinWindow).toBeGreaterThan(0);
|
|
332
|
+
|
|
333
|
+
await vi.advanceTimersByTimeAsync(3 * INTERVAL_MS);
|
|
334
|
+
expect(getRingState).toHaveBeenCalledTimes(callsWithinWindow);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('stops polling on an unrecoverable response, but not on a transient one', async () => {
|
|
338
|
+
const getRingState = vi.spyOn(call, 'getRingState');
|
|
339
|
+
const error = (status: number) =>
|
|
340
|
+
new ErrorFromResponse({
|
|
341
|
+
message: 'boom',
|
|
342
|
+
code: 16,
|
|
343
|
+
status,
|
|
344
|
+
// @ts-expect-error mocking only what we need for the test
|
|
345
|
+
response: {},
|
|
346
|
+
unrecoverable: false,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
getRingState.mockRejectedValueOnce(error(500));
|
|
350
|
+
poller = new RingStatePoller(call);
|
|
351
|
+
poller.start();
|
|
352
|
+
|
|
353
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS);
|
|
354
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
355
|
+
|
|
356
|
+
getRingState.mockRejectedValueOnce(error(404));
|
|
357
|
+
await vi.advanceTimersByTimeAsync(INTERVAL_MS);
|
|
358
|
+
expect(getRingState).toHaveBeenCalledTimes(2);
|
|
359
|
+
|
|
360
|
+
await vi.advanceTimersByTimeAsync(3 * INTERVAL_MS);
|
|
361
|
+
expect(getRingState).toHaveBeenCalledTimes(2);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('does not start without a call session', async () => {
|
|
365
|
+
call.state['sessionSubject'].next(undefined);
|
|
366
|
+
const getRingState = startPolling();
|
|
367
|
+
|
|
368
|
+
await vi.advanceTimersByTimeAsync(START_AFTER_MS + INTERVAL_MS);
|
|
369
|
+
expect(getRingState).not.toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('honors the configured timings', async () => {
|
|
373
|
+
const getRingState = vi
|
|
374
|
+
.spyOn(call, 'getRingState')
|
|
375
|
+
.mockResolvedValue(ringState());
|
|
376
|
+
poller = new RingStatePoller(call, {
|
|
377
|
+
startAfterMs: 1_000,
|
|
378
|
+
intervalMs: 500,
|
|
379
|
+
});
|
|
380
|
+
poller.start();
|
|
381
|
+
|
|
382
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
383
|
+
expect(getRingState).toHaveBeenCalledTimes(1);
|
|
384
|
+
|
|
385
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
386
|
+
expect(getRingState).toHaveBeenCalledTimes(2);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('Call ring state polling', () => {
|
|
391
|
+
const userId = 'jane';
|
|
392
|
+
|
|
393
|
+
const createRingingCall = (options?: StreamClientOptions) => {
|
|
394
|
+
const clientState = new ClientState();
|
|
395
|
+
const streamClient = new StreamClient('abc', options);
|
|
396
|
+
const call = new Call({
|
|
397
|
+
type: 'test',
|
|
398
|
+
id: generateUUIDv4(),
|
|
399
|
+
streamClient,
|
|
400
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
401
|
+
clientState,
|
|
402
|
+
ringing: true,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// @ts-expect-error mocking only what we need for the test
|
|
406
|
+
clientState.setConnectedUser({ id: userId });
|
|
407
|
+
// @ts-expect-error mocking only what we need for the test
|
|
408
|
+
call.state['sessionSubject'].next({
|
|
409
|
+
id: SESSION_ID,
|
|
410
|
+
accepted_by: {},
|
|
411
|
+
rejected_by: {},
|
|
412
|
+
missed_by: {},
|
|
413
|
+
participants: [],
|
|
414
|
+
});
|
|
415
|
+
call.state['settingsSubject'].next({
|
|
416
|
+
// @ts-expect-error mocking only what we need for the test
|
|
417
|
+
ring: { auto_cancel_timeout_ms: 30_000 },
|
|
418
|
+
// @ts-expect-error mocking only what we need for the test
|
|
419
|
+
screensharing: {
|
|
420
|
+
enabled: false,
|
|
421
|
+
target_resolution: { width: 100, height: 100 },
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
return call;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
beforeEach(() => {
|
|
428
|
+
vi.useFakeTimers();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
afterEach(() => {
|
|
432
|
+
vi.useRealTimers();
|
|
433
|
+
vi.restoreAllMocks();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('polls for the caller', () => {
|
|
437
|
+
const call = createRingingCall();
|
|
438
|
+
// @ts-expect-error mocking only what we need for the test
|
|
439
|
+
call.state['createdBySubject'].next({ id: userId });
|
|
440
|
+
|
|
441
|
+
call['handleRingingCall']();
|
|
442
|
+
expect(call['ringStatePoller']).toBeDefined();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('does not poll for the callee', () => {
|
|
446
|
+
const call = createRingingCall();
|
|
447
|
+
// @ts-expect-error mocking only what we need for the test
|
|
448
|
+
call.state['createdBySubject'].next({ id: 'not-' + userId });
|
|
449
|
+
|
|
450
|
+
call['handleRingingCall']();
|
|
451
|
+
expect(call['ringStatePoller']).toBeUndefined();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('does not poll when disabled through the client options', () => {
|
|
455
|
+
const call = createRingingCall({ ringStatePolling: false });
|
|
456
|
+
// @ts-expect-error mocking only what we need for the test
|
|
457
|
+
call.state['createdBySubject'].next({ id: userId });
|
|
458
|
+
|
|
459
|
+
call['handleRingingCall']();
|
|
460
|
+
expect(call['ringStatePoller']).toBeUndefined();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
5
|
+
import { Call } from '../../Call';
|
|
6
|
+
import { RingTimeout } from '../RingTimeout';
|
|
7
|
+
import { StreamClient } from '../../coordinator/connection/client';
|
|
8
|
+
import { ClientEventReporter } from '../../reporting';
|
|
9
|
+
import { generateUUIDv4 } from '../../coordinator/connection/utils';
|
|
10
|
+
import { CallingState, ClientState } from '../../store';
|
|
11
|
+
import { CallSettingsResponse } from '../../gen/coordinator';
|
|
12
|
+
|
|
13
|
+
const TIMEOUT_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
describe('RingTimeout', () => {
|
|
16
|
+
const userId = 'jane';
|
|
17
|
+
let call: Call;
|
|
18
|
+
let ringTimeout: RingTimeout;
|
|
19
|
+
|
|
20
|
+
const ringingCall = ({
|
|
21
|
+
createdById = userId,
|
|
22
|
+
ring = {
|
|
23
|
+
auto_cancel_timeout_ms: TIMEOUT_MS,
|
|
24
|
+
incoming_call_timeout_ms: TIMEOUT_MS,
|
|
25
|
+
missed_call_timeout_ms: TIMEOUT_MS,
|
|
26
|
+
},
|
|
27
|
+
}: {
|
|
28
|
+
createdById?: string;
|
|
29
|
+
ring?: Partial<CallSettingsResponse['ring']> | null;
|
|
30
|
+
} = {}) => {
|
|
31
|
+
const clientState = new ClientState();
|
|
32
|
+
const streamClient = new StreamClient('abc');
|
|
33
|
+
const newCall = new Call({
|
|
34
|
+
type: 'test',
|
|
35
|
+
id: generateUUIDv4(),
|
|
36
|
+
streamClient,
|
|
37
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
38
|
+
clientState,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
clientState.setConnectedUser(fromPartial({ id: userId }));
|
|
42
|
+
newCall.state['createdBySubject'].next(fromPartial({ id: createdById }));
|
|
43
|
+
// leaving `settings` unset is how a call built from a push notification
|
|
44
|
+
// looks until `get()` resolves
|
|
45
|
+
if (ring) {
|
|
46
|
+
newCall.state['settingsSubject'].next(
|
|
47
|
+
fromPartial<CallSettingsResponse>({
|
|
48
|
+
ring,
|
|
49
|
+
screensharing: {
|
|
50
|
+
enabled: false,
|
|
51
|
+
target_resolution: { width: 100, height: 100 },
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
newCall.state['callingStateSubject'].next(CallingState.RINGING);
|
|
57
|
+
|
|
58
|
+
vi.spyOn(newCall, 'leave').mockResolvedValue(undefined);
|
|
59
|
+
return newCall;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const arm = () => {
|
|
63
|
+
ringTimeout = new RingTimeout(call);
|
|
64
|
+
ringTimeout.start();
|
|
65
|
+
return ringTimeout;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
ringTimeout?.stop();
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('drops the call for the caller when no one accepted', async () => {
|
|
79
|
+
call = ringingCall();
|
|
80
|
+
arm();
|
|
81
|
+
|
|
82
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
83
|
+
|
|
84
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
85
|
+
reject: true,
|
|
86
|
+
reason: 'timeout',
|
|
87
|
+
message: 'ringing timeout - no one accepted',
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('drops the call for a callee that never interacted', async () => {
|
|
92
|
+
call = ringingCall({ createdById: 'not-' + userId });
|
|
93
|
+
arm();
|
|
94
|
+
|
|
95
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
96
|
+
|
|
97
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
98
|
+
reject: true,
|
|
99
|
+
reason: 'timeout',
|
|
100
|
+
message: `ringing timeout - user didn't interact with incoming call screen`,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not drop the call before the timeout elapses', async () => {
|
|
105
|
+
call = ringingCall();
|
|
106
|
+
arm();
|
|
107
|
+
|
|
108
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS - 1);
|
|
109
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
110
|
+
|
|
111
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
112
|
+
expect(call.leave).toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not drop a call that stopped ringing before the deadline', async () => {
|
|
116
|
+
call = ringingCall();
|
|
117
|
+
arm();
|
|
118
|
+
|
|
119
|
+
call.state['callingStateSubject'].next(CallingState.JOINED);
|
|
120
|
+
|
|
121
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
122
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// `doJoin` restores the ringing state when a join fails, so a transition to
|
|
126
|
+
// JOINING must not disarm the drop for good, or the call rings forever.
|
|
127
|
+
it('still drops the call after a join attempt failed and left it ringing', async () => {
|
|
128
|
+
call = ringingCall();
|
|
129
|
+
arm();
|
|
130
|
+
|
|
131
|
+
call.state['callingStateSubject'].next(CallingState.JOINING);
|
|
132
|
+
call.state['callingStateSubject'].next(CallingState.RINGING);
|
|
133
|
+
|
|
134
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
135
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
136
|
+
reject: true,
|
|
137
|
+
reason: 'timeout',
|
|
138
|
+
message: 'ringing timeout - no one accepted',
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not drop the call once stopped', async () => {
|
|
143
|
+
call = ringingCall();
|
|
144
|
+
arm().stop();
|
|
145
|
+
|
|
146
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
147
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('resumes from the original deadline after being paused', async () => {
|
|
151
|
+
call = ringingCall();
|
|
152
|
+
arm();
|
|
153
|
+
|
|
154
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
155
|
+
ringTimeout.pause();
|
|
156
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
157
|
+
ringTimeout.start();
|
|
158
|
+
|
|
159
|
+
await vi.advanceTimersByTimeAsync(14_999);
|
|
160
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
161
|
+
|
|
162
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
163
|
+
expect(call.leave).toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('does not arm when the call is not ringing', async () => {
|
|
167
|
+
call = ringingCall();
|
|
168
|
+
call.state['callingStateSubject'].next(CallingState.JOINED);
|
|
169
|
+
arm();
|
|
170
|
+
|
|
171
|
+
expect(ringTimeout['timeoutId']).toBeUndefined();
|
|
172
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
173
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('does not arm before the call settings have loaded', async () => {
|
|
177
|
+
call = ringingCall({ ring: null });
|
|
178
|
+
arm();
|
|
179
|
+
|
|
180
|
+
expect(ringTimeout['timeoutId']).toBeUndefined();
|
|
181
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
182
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('treats a zero timeout as no auto-drop', async () => {
|
|
186
|
+
call = ringingCall({
|
|
187
|
+
ring: {
|
|
188
|
+
auto_cancel_timeout_ms: 0,
|
|
189
|
+
incoming_call_timeout_ms: 0,
|
|
190
|
+
missed_call_timeout_ms: TIMEOUT_MS,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
arm();
|
|
194
|
+
|
|
195
|
+
expect(ringTimeout['timeoutId']).toBeUndefined();
|
|
196
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
197
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
});
|