@stream-io/video-client 1.59.1 → 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 +18 -0
- package/dist/index.browser.es.js +744 -156
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +744 -155
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +744 -156
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +34 -8
- 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 +10 -1
- package/dist/src/types.d.ts +30 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +272 -108
- package/src/StreamSfuClient.ts +6 -2
- package/src/__tests__/Call.autodrop.test.ts +116 -64
- package/src/__tests__/Call.getRingState.test.ts +87 -0
- package/src/__tests__/Call.lifecycle.test.ts +498 -5
- 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/coordinator/connection/types.ts +19 -0
- package/src/events/__tests__/call.test.ts +2 -217
- package/src/events/call.ts +1 -73
- package/src/events/callEventHandlers.ts +12 -23
- package/src/gen/coordinator/index.ts +67 -0
- 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__/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 +25 -0
- package/src/store/__tests__/CallState.test.ts +59 -0
- package/src/types.ts +37 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Call } from '../Call';
|
|
2
|
+
import { CallingState } from '../store';
|
|
3
|
+
import { getTimers } from '../timers';
|
|
4
|
+
import { videoLoggerSystem } from '../logger';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drops a call that has been ringing for too long: the caller cancels after
|
|
8
|
+
* `auto_cancel_timeout_ms`, a callee after `incoming_call_timeout_ms`.
|
|
9
|
+
*
|
|
10
|
+
* Either timeout being `0` means the call rings until something else settles it.
|
|
11
|
+
*/
|
|
12
|
+
export class RingTimeout {
|
|
13
|
+
private readonly call: Call;
|
|
14
|
+
private timeoutId: number | undefined;
|
|
15
|
+
private deadlineAt: number | undefined;
|
|
16
|
+
private stopped: boolean = false;
|
|
17
|
+
|
|
18
|
+
constructor(call: Call) {
|
|
19
|
+
this.call = call;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Schedules an auto-drop timeout based on the call settings.
|
|
24
|
+
* Applicable only for ringing calls.
|
|
25
|
+
*/
|
|
26
|
+
start = () => {
|
|
27
|
+
if (this.stopped || this.timeoutId !== undefined) return;
|
|
28
|
+
// ignore if the call is not ringing
|
|
29
|
+
if (this.call.state.callingState !== CallingState.RINGING) return;
|
|
30
|
+
|
|
31
|
+
const ring = this.call.state.settings?.ring;
|
|
32
|
+
if (!ring) return;
|
|
33
|
+
|
|
34
|
+
const isCaller = this.call.isCreatedByMe;
|
|
35
|
+
const timeoutMs = isCaller
|
|
36
|
+
? ring.auto_cancel_timeout_ms
|
|
37
|
+
: ring.incoming_call_timeout_ms;
|
|
38
|
+
// 0 means no auto-drop
|
|
39
|
+
if (timeoutMs <= 0) return;
|
|
40
|
+
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
this.deadlineAt ??= now + timeoutMs;
|
|
43
|
+
const delayMs = Math.max(0, this.deadlineAt - now);
|
|
44
|
+
const timers = getTimers();
|
|
45
|
+
this.timeoutId = timers.setTimeout(() => {
|
|
46
|
+
this.timeoutId = undefined;
|
|
47
|
+
// A failed timeout-triggered leave can arm a fresh retry window.
|
|
48
|
+
this.deadlineAt = undefined;
|
|
49
|
+
if (this.stopped) return;
|
|
50
|
+
// the call might have stopped ringing by this point, e.g. it was already
|
|
51
|
+
// accepted and joined
|
|
52
|
+
if (this.call.state.callingState !== CallingState.RINGING) return;
|
|
53
|
+
this.call
|
|
54
|
+
.leave({
|
|
55
|
+
reject: true,
|
|
56
|
+
reason: 'timeout',
|
|
57
|
+
message: `ringing timeout - ${
|
|
58
|
+
isCaller
|
|
59
|
+
? 'no one accepted'
|
|
60
|
+
: `user didn't interact with incoming call screen`
|
|
61
|
+
}`,
|
|
62
|
+
})
|
|
63
|
+
.catch((err) => {
|
|
64
|
+
videoLoggerSystem
|
|
65
|
+
.getLogger('RingTimeout')
|
|
66
|
+
.error('Failed to drop the call', err);
|
|
67
|
+
});
|
|
68
|
+
}, delayMs);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pauses the timeout while preserving its original deadline.
|
|
73
|
+
*/
|
|
74
|
+
pause = () => {
|
|
75
|
+
if (this.stopped || this.timeoutId === undefined) return;
|
|
76
|
+
getTimers().clearTimeout(this.timeoutId);
|
|
77
|
+
this.timeoutId = undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Cancels a scheduled auto-drop timeout. It cannot be armed again.
|
|
82
|
+
*/
|
|
83
|
+
stop = () => {
|
|
84
|
+
if (this.stopped) return;
|
|
85
|
+
this.stopped = true;
|
|
86
|
+
getTimers().clearTimeout(this.timeoutId);
|
|
87
|
+
this.timeoutId = undefined;
|
|
88
|
+
this.deadlineAt = undefined;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -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, StreamVideoWriteableStateStore } 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 clientStore = new StreamVideoWriteableStateStore();
|
|
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
|
+
clientStore,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// @ts-expect-error mocking only what we need for the test
|
|
51
|
+
clientStore.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 clientStore = new StreamVideoWriteableStateStore();
|
|
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
|
+
clientStore,
|
|
402
|
+
ringing: true,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// @ts-expect-error mocking only what we need for the test
|
|
406
|
+
clientStore.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
|
+
});
|