@stream-io/video-client 1.59.1 → 1.60.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 +7 -0
- package/dist/index.browser.es.js +495 -105
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +495 -105
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +495 -105
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +23 -3
- 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/stats/utils.d.ts +6 -0
- package/dist/src/store/CallState.d.ts +10 -1
- package/package.json +1 -1
- package/src/Call.ts +93 -61
- 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 +23 -0
- package/src/__tests__/Call.ringSettled.test.ts +71 -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/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/Call.ts
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
EndCallResponse,
|
|
51
51
|
GetCallReportResponse,
|
|
52
52
|
GetCallResponse,
|
|
53
|
+
GetCallRingStateResponse,
|
|
53
54
|
GetCallSessionParticipantStatsDetailsResponse,
|
|
54
55
|
GetOrCreateCallRequest,
|
|
55
56
|
GetOrCreateCallResponse,
|
|
@@ -152,7 +153,7 @@ import {
|
|
|
152
153
|
StatsReporter,
|
|
153
154
|
Tracer,
|
|
154
155
|
} from './stats';
|
|
155
|
-
import type { ClientEventReporter, JoinReason } from './reporting';
|
|
156
|
+
import type { ClientEventReporter, JoinReason, JoinSource } from './reporting';
|
|
156
157
|
import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
|
|
157
158
|
import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
|
|
158
159
|
import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
|
|
@@ -160,6 +161,7 @@ import { DynascaleManager } from './helpers/DynascaleManager';
|
|
|
160
161
|
import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame';
|
|
161
162
|
import { ViewportTracker } from './helpers/ViewportTracker';
|
|
162
163
|
import { PermissionsContext } from './permissions';
|
|
164
|
+
import { RingStatePoller, RingTimeout, resolveOwnRingOutcome } from './ringing';
|
|
163
165
|
import { CallTypes } from './CallType';
|
|
164
166
|
import { StreamClient } from './coordinator/connection/client';
|
|
165
167
|
import { retryInterval, sleep } from './coordinator/connection/utils';
|
|
@@ -302,7 +304,8 @@ export class Call {
|
|
|
302
304
|
private statsReporter?: StatsReporter;
|
|
303
305
|
private sfuStatsReporter?: SfuStatsReporter;
|
|
304
306
|
private lastStatsOptions?: StatsOptions;
|
|
305
|
-
private
|
|
307
|
+
private ringTimeout: RingTimeout | undefined;
|
|
308
|
+
private ringStatePoller: RingStatePoller | undefined;
|
|
306
309
|
|
|
307
310
|
private readonly clientStore: StreamVideoWriteableStateStore;
|
|
308
311
|
public readonly streamClient: StreamClient;
|
|
@@ -537,33 +540,20 @@ export class Call {
|
|
|
537
540
|
createSubscription(this.state.session$, (session) => {
|
|
538
541
|
if (!this.ringing) return;
|
|
539
542
|
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (
|
|
547
|
-
this.cancelAutoDrop();
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const isAcceptedElsewhere =
|
|
551
|
-
isAcceptedByMe && this.state.callingState === CallingState.RINGING;
|
|
543
|
+
const { settledByMe, leaveReason } = resolveOwnRingOutcome({
|
|
544
|
+
session,
|
|
545
|
+
currentUserId: this.currentUserId,
|
|
546
|
+
callingState: this.state.callingState,
|
|
547
|
+
});
|
|
548
|
+
if (settledByMe) this.cancelAutoDrop();
|
|
549
|
+
if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag)) return;
|
|
552
550
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(
|
|
558
|
-
this,
|
|
559
|
-
isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
|
|
551
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
|
|
552
|
+
this.leave().catch(() => {
|
|
553
|
+
this.logger.error(
|
|
554
|
+
'Could not leave a call that was accepted or rejected elsewhere',
|
|
560
555
|
);
|
|
561
|
-
|
|
562
|
-
this.logger.error(
|
|
563
|
-
'Could not leave a call that was accepted or rejected elsewhere',
|
|
564
|
-
);
|
|
565
|
-
});
|
|
566
|
-
}
|
|
556
|
+
});
|
|
567
557
|
}),
|
|
568
558
|
);
|
|
569
559
|
};
|
|
@@ -608,6 +598,7 @@ export class Call {
|
|
|
608
598
|
this.state.setCallingState(CallingState.RINGING);
|
|
609
599
|
}
|
|
610
600
|
this.scheduleAutoDrop();
|
|
601
|
+
this.scheduleRingStatePolling();
|
|
611
602
|
this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
|
|
612
603
|
}
|
|
613
604
|
};
|
|
@@ -713,6 +704,13 @@ export class Call {
|
|
|
713
704
|
throw new Error('Cannot leave call that has already been left.');
|
|
714
705
|
}
|
|
715
706
|
|
|
707
|
+
// before the first await: the calling state stays RINGING well into the
|
|
708
|
+
// teardown, so pause both watchdogs before they can race this leave. They
|
|
709
|
+
// keep their deadlines, so a failed leave resumes them without handing the
|
|
710
|
+
// ring another window.
|
|
711
|
+
this.ringTimeout?.pause();
|
|
712
|
+
this.ringStatePoller?.pause();
|
|
713
|
+
|
|
716
714
|
await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
|
|
717
715
|
const callingState = this.state.callingState;
|
|
718
716
|
|
|
@@ -821,6 +819,7 @@ export class Call {
|
|
|
821
819
|
this.unifiedSessionId = undefined;
|
|
822
820
|
this.ringingSubject.next(false);
|
|
823
821
|
this.cancelAutoDrop();
|
|
822
|
+
this.cancelRingStatePolling();
|
|
824
823
|
this.clientStore.unregisterCall(this);
|
|
825
824
|
|
|
826
825
|
globalThis.streamRNVideoSDK?.callManager.stop({
|
|
@@ -863,6 +862,17 @@ export class Call {
|
|
|
863
862
|
this.logger.warn('Failed to dispose media engine', err);
|
|
864
863
|
});
|
|
865
864
|
}
|
|
865
|
+
}).catch((err) => {
|
|
866
|
+
if (
|
|
867
|
+
!hasPending(this.joinLeaveConcurrencyTag) &&
|
|
868
|
+
this.state.callingState === CallingState.RINGING
|
|
869
|
+
) {
|
|
870
|
+
// resume, never re-arm: a fresh watchdog would restart the ring
|
|
871
|
+
// window this leave was already most of the way through
|
|
872
|
+
this.ringTimeout?.start();
|
|
873
|
+
this.ringStatePoller?.resume();
|
|
874
|
+
}
|
|
875
|
+
throw err;
|
|
866
876
|
});
|
|
867
877
|
};
|
|
868
878
|
|
|
@@ -1056,6 +1066,27 @@ export class Call {
|
|
|
1056
1066
|
);
|
|
1057
1067
|
};
|
|
1058
1068
|
|
|
1069
|
+
/**
|
|
1070
|
+
* Returns who accepted, rejected or missed the ring for a call session.
|
|
1071
|
+
* Safe to poll: it performs no writes and emits no events.
|
|
1072
|
+
*
|
|
1073
|
+
* @param callSessionId the call session to read. Defaults to the current one.
|
|
1074
|
+
* Pass it explicitly to read a session that has already ended, as ending a
|
|
1075
|
+
* call clears its current session.
|
|
1076
|
+
*/
|
|
1077
|
+
getRingState = async (
|
|
1078
|
+
callSessionId?: string,
|
|
1079
|
+
): Promise<GetCallRingStateResponse> => {
|
|
1080
|
+
const sessionId = callSessionId ?? this.state.session?.id;
|
|
1081
|
+
if (!sessionId) {
|
|
1082
|
+
throw new Error('Cannot read the ring state: the call has no session');
|
|
1083
|
+
}
|
|
1084
|
+
return this.streamClient.get<GetCallRingStateResponse>(
|
|
1085
|
+
`${this.streamClientBasePath}/ring_state`,
|
|
1086
|
+
{ call_session_id: sessionId },
|
|
1087
|
+
);
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1059
1090
|
/**
|
|
1060
1091
|
* A shortcut for {@link Call.get} with `notify` parameter set to `true`.
|
|
1061
1092
|
* Will send a `call.notification` event to the call members.
|
|
@@ -1112,12 +1143,14 @@ export class Call {
|
|
|
1112
1143
|
joinResponseTimeout,
|
|
1113
1144
|
rpcRequestTimeout,
|
|
1114
1145
|
allowOwnTracksLoopback = false,
|
|
1146
|
+
joinSource,
|
|
1115
1147
|
...data
|
|
1116
1148
|
}: JoinCallData & {
|
|
1117
1149
|
maxJoinRetries?: number;
|
|
1118
1150
|
joinResponseTimeout?: number;
|
|
1119
1151
|
rpcRequestTimeout?: number;
|
|
1120
1152
|
allowOwnTracksLoopback?: boolean;
|
|
1153
|
+
joinSource?: JoinSource;
|
|
1121
1154
|
} = {}): Promise<void> => {
|
|
1122
1155
|
const callingState = this.state.callingState;
|
|
1123
1156
|
|
|
@@ -1159,7 +1192,7 @@ export class Call {
|
|
|
1159
1192
|
try {
|
|
1160
1193
|
await this.clientEventReporter.withJoinLifecycle(
|
|
1161
1194
|
this.cid,
|
|
1162
|
-
'first-attempt',
|
|
1195
|
+
{ joinReason: 'first-attempt', joinSource },
|
|
1163
1196
|
async () => {
|
|
1164
1197
|
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
1165
1198
|
try {
|
|
@@ -2081,8 +2114,10 @@ export class Call {
|
|
|
2081
2114
|
this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
|
|
2082
2115
|
? 'network-available'
|
|
2083
2116
|
: 'full-rejoin';
|
|
2084
|
-
await this.clientEventReporter.withJoinLifecycle(
|
|
2085
|
-
this.
|
|
2117
|
+
await this.clientEventReporter.withJoinLifecycle(
|
|
2118
|
+
this.cid,
|
|
2119
|
+
{ joinReason },
|
|
2120
|
+
() => this.doJoin(this.joinCallData),
|
|
2086
2121
|
);
|
|
2087
2122
|
await this.restorePublishedTracks();
|
|
2088
2123
|
this.restoreSubscribedTracks();
|
|
@@ -2117,7 +2152,7 @@ export class Call {
|
|
|
2117
2152
|
const currentSfu = currentSfuClient.edgeName;
|
|
2118
2153
|
await this.clientEventReporter.withJoinLifecycle(
|
|
2119
2154
|
this.cid,
|
|
2120
|
-
'migration',
|
|
2155
|
+
{ joinReason: 'migration' },
|
|
2121
2156
|
() =>
|
|
2122
2157
|
this.doJoin({
|
|
2123
2158
|
...this.joinCallData,
|
|
@@ -3114,42 +3149,39 @@ export class Call {
|
|
|
3114
3149
|
*/
|
|
3115
3150
|
private scheduleAutoDrop = () => {
|
|
3116
3151
|
this.cancelAutoDrop();
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
if (!settings) return;
|
|
3120
|
-
// ignore if the call is not ringing
|
|
3121
|
-
if (this.state.callingState !== CallingState.RINGING) return;
|
|
3122
|
-
|
|
3123
|
-
const timeoutInMs = this.isCreatedByMe
|
|
3124
|
-
? settings.ring.auto_cancel_timeout_ms
|
|
3125
|
-
: settings.ring.incoming_call_timeout_ms;
|
|
3126
|
-
|
|
3127
|
-
// 0 means no auto-drop
|
|
3128
|
-
if (timeoutInMs <= 0) return;
|
|
3129
|
-
this.dropTimeout = setTimeout(() => {
|
|
3130
|
-
// the call might have stopped ringing by this point,
|
|
3131
|
-
// e.g. it was already accepted and joined
|
|
3132
|
-
if (this.state.callingState !== CallingState.RINGING) return;
|
|
3133
|
-
this.leave({
|
|
3134
|
-
reject: true,
|
|
3135
|
-
reason: 'timeout',
|
|
3136
|
-
message: `ringing timeout - ${
|
|
3137
|
-
this.isCreatedByMe
|
|
3138
|
-
? 'no one accepted'
|
|
3139
|
-
: `user didn't interact with incoming call screen`
|
|
3140
|
-
}`,
|
|
3141
|
-
}).catch((err) => {
|
|
3142
|
-
this.logger.error('Failed to drop call', err);
|
|
3143
|
-
});
|
|
3144
|
-
}, timeoutInMs);
|
|
3152
|
+
this.ringTimeout = new RingTimeout(this);
|
|
3153
|
+
this.ringTimeout.start();
|
|
3145
3154
|
};
|
|
3146
3155
|
|
|
3147
3156
|
/**
|
|
3148
3157
|
* Cancels a scheduled auto-drop timeout.
|
|
3149
3158
|
*/
|
|
3150
3159
|
private cancelAutoDrop = () => {
|
|
3151
|
-
|
|
3152
|
-
this.
|
|
3160
|
+
this.ringTimeout?.stop();
|
|
3161
|
+
this.ringTimeout = undefined;
|
|
3162
|
+
};
|
|
3163
|
+
|
|
3164
|
+
/**
|
|
3165
|
+
* Starts polling for the ring outcome. Applicable only to ringing calls the
|
|
3166
|
+
* current user created.
|
|
3167
|
+
*/
|
|
3168
|
+
private scheduleRingStatePolling = () => {
|
|
3169
|
+
this.cancelRingStatePolling();
|
|
3170
|
+
|
|
3171
|
+
if (!this.isCreatedByMe) return;
|
|
3172
|
+
const options = this.streamClient.options.ringStatePolling;
|
|
3173
|
+
if (options === false) return;
|
|
3174
|
+
|
|
3175
|
+
this.ringStatePoller = new RingStatePoller(this, options);
|
|
3176
|
+
this.ringStatePoller.start();
|
|
3177
|
+
};
|
|
3178
|
+
|
|
3179
|
+
/**
|
|
3180
|
+
* Cancels the ring state polling.
|
|
3181
|
+
*/
|
|
3182
|
+
private cancelRingStatePolling = () => {
|
|
3183
|
+
this.ringStatePoller?.stop();
|
|
3184
|
+
this.ringStatePoller = undefined;
|
|
3153
3185
|
};
|
|
3154
3186
|
|
|
3155
3187
|
/**
|
package/src/StreamSfuClient.ts
CHANGED
|
@@ -39,7 +39,8 @@ import {
|
|
|
39
39
|
} from './helpers/promise';
|
|
40
40
|
import { withoutConcurrency } from './helpers/concurrency';
|
|
41
41
|
import { getTimers } from './timers';
|
|
42
|
-
import {
|
|
42
|
+
import { getSdkInfo } from './helpers/client-details';
|
|
43
|
+
import { getStreamClientId, Tracer, TraceSlice } from './stats';
|
|
43
44
|
import { SfuJoinError, SfuTimeoutError } from './errors';
|
|
44
45
|
|
|
45
46
|
export type StreamSfuClientConstructor = {
|
|
@@ -271,7 +272,10 @@ export class StreamSfuClient {
|
|
|
271
272
|
this.rpc = createSignalClient({
|
|
272
273
|
baseUrl: server.url,
|
|
273
274
|
interceptors: [
|
|
274
|
-
withHeaders({
|
|
275
|
+
withHeaders({
|
|
276
|
+
Authorization: `Bearer ${token}`,
|
|
277
|
+
'X-Stream-Client': getStreamClientId(getSdkInfo()),
|
|
278
|
+
}),
|
|
275
279
|
this.tracer && withRequestTracer(this.tracer.trace),
|
|
276
280
|
this.logger.getLogLevel() === 'trace' && withRequestLogger(this.logger),
|
|
277
281
|
withTimeout(rpcRequestTimeout, this.tracer?.trace),
|
|
@@ -1,104 +1,156 @@
|
|
|
1
1
|
import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
4
5
|
import { Call } from '../Call';
|
|
5
6
|
import { StreamClient } from '../coordinator/connection/client';
|
|
6
7
|
import { ClientEventReporter } from '../reporting';
|
|
7
8
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
8
9
|
import { CallingState, StreamVideoWriteableStateStore } from '../store';
|
|
10
|
+
import { CallSettingsResponse } from '../gen/coordinator';
|
|
9
11
|
|
|
12
|
+
const TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
// The timeout's own behaviour is covered by `ringing/__tests__/RingTimeout`.
|
|
15
|
+
// This file covers the wiring: that a ringing call arms one and that leaving
|
|
16
|
+
// cancels it.
|
|
10
17
|
describe('Auto drop ringing calls', () => {
|
|
11
|
-
let call: Call;
|
|
12
18
|
const userId = 'jane';
|
|
19
|
+
let call: Call;
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
vi.useFakeTimers();
|
|
16
|
-
|
|
21
|
+
const ringingCall = () => {
|
|
17
22
|
const clientStore = new StreamVideoWriteableStateStore();
|
|
18
23
|
const streamClient = new StreamClient('abc');
|
|
19
|
-
|
|
24
|
+
const newCall = new Call({
|
|
20
25
|
type: 'test',
|
|
21
26
|
id: generateUUIDv4(),
|
|
22
27
|
streamClient,
|
|
23
28
|
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
24
|
-
clientStore
|
|
29
|
+
clientStore,
|
|
30
|
+
ringing: true,
|
|
25
31
|
});
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
console.log(`TEST: leave() called`);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('caller should drop ringing calls after a timeout if no one accepted', async () => {
|
|
40
|
-
call.state['settingsSubject'].next({
|
|
41
|
-
// @ts-expect-error mocking only what we need for the test, we use fake timers, so undefined for timeout works
|
|
42
|
-
ring: {},
|
|
43
|
-
// @ts-expect-error mocking only what we need for the test
|
|
44
|
-
screensharing: {
|
|
45
|
-
enabled: false,
|
|
46
|
-
target_resolution: {
|
|
47
|
-
width: 100,
|
|
48
|
-
height: 100,
|
|
33
|
+
clientStore.setConnectedUser(fromPartial({ id: userId }));
|
|
34
|
+
newCall.state['createdBySubject'].next(fromPartial({ id: userId }));
|
|
35
|
+
newCall.state['settingsSubject'].next(
|
|
36
|
+
fromPartial<CallSettingsResponse>({
|
|
37
|
+
ring: {
|
|
38
|
+
auto_cancel_timeout_ms: TIMEOUT_MS,
|
|
39
|
+
incoming_call_timeout_ms: TIMEOUT_MS,
|
|
40
|
+
missed_call_timeout_ms: TIMEOUT_MS,
|
|
49
41
|
},
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
screensharing: {
|
|
43
|
+
enabled: false,
|
|
44
|
+
target_resolution: { width: 100, height: 100 },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
newCall.state['sessionSubject'].next(
|
|
49
|
+
fromPartial({
|
|
50
|
+
id: 'session-1',
|
|
51
|
+
accepted_by: {},
|
|
52
|
+
rejected_by: {},
|
|
53
|
+
missed_by: {},
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
newCall.state['callingStateSubject'].next(CallingState.RINGING);
|
|
57
|
+
|
|
58
|
+
vi.spyOn(newCall, 'leave').mockResolvedValue(undefined);
|
|
59
|
+
return newCall;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
call = ringingCall();
|
|
65
|
+
});
|
|
52
66
|
|
|
53
|
-
|
|
54
|
-
call
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
call['cancelAutoDrop']();
|
|
69
|
+
vi.useRealTimers();
|
|
70
|
+
vi.restoreAllMocks();
|
|
71
|
+
});
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
call['
|
|
73
|
+
it('is armed when a call starts ringing', async () => {
|
|
74
|
+
call.state['callingStateSubject'].next(CallingState.IDLE);
|
|
60
75
|
|
|
61
|
-
|
|
76
|
+
call['handleRingingCall']();
|
|
62
77
|
|
|
78
|
+
expect(call['ringTimeout']).toBeDefined();
|
|
79
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
63
80
|
expect(call.leave).toHaveBeenCalledWith({
|
|
64
81
|
reject: true,
|
|
65
82
|
reason: 'timeout',
|
|
66
|
-
message:
|
|
83
|
+
message: 'ringing timeout - no one accepted',
|
|
67
84
|
});
|
|
68
85
|
});
|
|
69
86
|
|
|
70
|
-
it(
|
|
71
|
-
call
|
|
72
|
-
// @ts-expect-error mocking only what we need for the test, we use fake timers, so undefined for timeout works
|
|
73
|
-
ring: {},
|
|
74
|
-
// @ts-expect-error mocking only what we need for the test
|
|
75
|
-
screensharing: {
|
|
76
|
-
enabled: false,
|
|
77
|
-
target_resolution: {
|
|
78
|
-
width: 100,
|
|
79
|
-
height: 100,
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
});
|
|
87
|
+
it('is cancelled by cancelAutoDrop', async () => {
|
|
88
|
+
call['scheduleAutoDrop']();
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
call.state['createdBySubject'].next({
|
|
86
|
-
id: 'not-' + userId,
|
|
87
|
-
});
|
|
90
|
+
call['cancelAutoDrop']();
|
|
88
91
|
|
|
89
|
-
|
|
92
|
+
expect(call['ringTimeout']).toBeUndefined();
|
|
93
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
94
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// the calling state stays RINGING well into teardown, so the watchdogs have
|
|
98
|
+
// to be paused before `leave` awaits anything
|
|
99
|
+
it('pauses the watchdogs synchronously when leave starts', async () => {
|
|
90
100
|
call['scheduleAutoDrop']();
|
|
101
|
+
call['scheduleRingStatePolling']();
|
|
102
|
+
const timeout = call['ringTimeout'];
|
|
103
|
+
vi.spyOn(call, 'leave').mockRestore();
|
|
104
|
+
vi.spyOn(call, 'reject').mockResolvedValue(fromPartial({}));
|
|
91
105
|
|
|
92
|
-
|
|
106
|
+
const poller = call['ringStatePoller'];
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
const leaving = call.leave({ reject: false });
|
|
109
|
+
|
|
110
|
+
// paused, not cancelled: both keep the window this leave is ending
|
|
111
|
+
expect(call['ringTimeout']).toBe(timeout);
|
|
112
|
+
expect(call['ringStatePoller']).toBe(poller);
|
|
113
|
+
expect(timeout!['timeoutId']).toBeUndefined();
|
|
114
|
+
expect(timeout!['stopped']).toBe(false);
|
|
115
|
+
expect(poller!['idleTimeoutId']).toBeUndefined();
|
|
116
|
+
expect(poller!['stopped']).toBe(false);
|
|
117
|
+
|
|
118
|
+
await leaving.catch(() => {});
|
|
119
|
+
|
|
120
|
+
expect(timeout!['stopped']).toBe(true);
|
|
121
|
+
expect(poller!['stopped']).toBe(true);
|
|
99
122
|
});
|
|
100
123
|
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
it('restores both watchdogs when rejecting the ring fails', async () => {
|
|
125
|
+
call['scheduleAutoDrop']();
|
|
126
|
+
call['scheduleRingStatePolling']();
|
|
127
|
+
const timeout = call['ringTimeout'];
|
|
128
|
+
const deadlineAt = timeout!['deadlineAt'];
|
|
129
|
+
const poller = call['ringStatePoller'];
|
|
130
|
+
const pollDeadlineAt = poller!['deadlineAt'];
|
|
131
|
+
vi.spyOn(call, 'leave').mockRestore();
|
|
132
|
+
vi.spyOn(call, 'reject').mockRejectedValueOnce(new Error('transient'));
|
|
133
|
+
|
|
134
|
+
await expect(call.leave({ reject: true })).rejects.toThrow('transient');
|
|
135
|
+
|
|
136
|
+
expect(call.state.callingState).toBe(CallingState.RINGING);
|
|
137
|
+
expect(call['ringTimeout']).toBe(timeout);
|
|
138
|
+
expect(timeout!['timeoutId']).toBeDefined();
|
|
139
|
+
expect(timeout!['deadlineAt']).toBe(deadlineAt);
|
|
140
|
+
expect(call['ringStatePoller']).toBe(poller);
|
|
141
|
+
expect(poller!['deadlineAt']).toBe(pollDeadlineAt);
|
|
142
|
+
expect(poller!['stopped']).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('replaces a previously armed timeout', async () => {
|
|
146
|
+
call['scheduleAutoDrop']();
|
|
147
|
+
const first = call['ringTimeout'];
|
|
148
|
+
|
|
149
|
+
call['scheduleAutoDrop']();
|
|
150
|
+
|
|
151
|
+
expect(call['ringTimeout']).not.toBe(first);
|
|
152
|
+
expect(first!['stopped']).toBe(true);
|
|
153
|
+
await vi.advanceTimersByTimeAsync(TIMEOUT_MS);
|
|
154
|
+
expect(call.leave).toHaveBeenCalledTimes(1);
|
|
103
155
|
});
|
|
104
156
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
5
|
+
import { Call } from '../Call';
|
|
6
|
+
import { StreamClient } from '../coordinator/connection/client';
|
|
7
|
+
import { ClientEventReporter } from '../reporting';
|
|
8
|
+
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
9
|
+
import { StreamVideoWriteableStateStore } from '../store';
|
|
10
|
+
import { CallSessionResponse } from '../gen/coordinator';
|
|
11
|
+
|
|
12
|
+
describe('Call.getRingState', () => {
|
|
13
|
+
const callId = generateUUIDv4();
|
|
14
|
+
|
|
15
|
+
const fakeCall = (sessionId?: string) => {
|
|
16
|
+
const streamClient = new StreamClient('abc');
|
|
17
|
+
const call = new Call({
|
|
18
|
+
type: 'test',
|
|
19
|
+
id: callId,
|
|
20
|
+
streamClient,
|
|
21
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
22
|
+
clientStore: new StreamVideoWriteableStateStore(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (sessionId) {
|
|
26
|
+
call.state['sessionSubject'].next(
|
|
27
|
+
fromPartial<CallSessionResponse>({ id: sessionId }),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const get = vi
|
|
32
|
+
.spyOn(streamClient, 'get')
|
|
33
|
+
.mockResolvedValue(fromPartial({ session_id: sessionId }));
|
|
34
|
+
|
|
35
|
+
return { call, get };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('reads the ring state of the current session', async () => {
|
|
43
|
+
const { call, get } = fakeCall('session-1');
|
|
44
|
+
|
|
45
|
+
await call.getRingState();
|
|
46
|
+
|
|
47
|
+
expect(get).toHaveBeenCalledWith(`/call/test/${callId}/ring_state`, {
|
|
48
|
+
call_session_id: 'session-1',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('reads the ring state of an explicitly named session', async () => {
|
|
53
|
+
// ending a call clears its current session, so a caller reconciling after
|
|
54
|
+
// `call.ended` has to name the session it rang on
|
|
55
|
+
const { call, get } = fakeCall('current-session');
|
|
56
|
+
|
|
57
|
+
await call.getRingState('ended-session');
|
|
58
|
+
|
|
59
|
+
expect(get).toHaveBeenCalledWith(`/call/test/${callId}/ring_state`, {
|
|
60
|
+
call_session_id: 'ended-session',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns the coordinator response', async () => {
|
|
65
|
+
const { call, get } = fakeCall('session-1');
|
|
66
|
+
get.mockResolvedValue(
|
|
67
|
+
fromPartial({
|
|
68
|
+
session_id: 'session-1',
|
|
69
|
+
accepted_by: { bob: '2026-08-24T10:00:04Z' },
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
await expect(call.getRingState()).resolves.toMatchObject({
|
|
74
|
+
session_id: 'session-1',
|
|
75
|
+
accepted_by: { bob: '2026-08-24T10:00:04Z' },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects when the call has no session to read', async () => {
|
|
80
|
+
const { call, get } = fakeCall();
|
|
81
|
+
|
|
82
|
+
await expect(call.getRingState()).rejects.toThrow(
|
|
83
|
+
'Cannot read the ring state: the call has no session',
|
|
84
|
+
);
|
|
85
|
+
expect(get).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -79,6 +79,29 @@ describe('Call lifecycle wiring', () => {
|
|
|
79
79
|
expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
// `joinSource` is reporting-only: it must reach the event reporter and never
|
|
83
|
+
// the coordinator's join request.
|
|
84
|
+
it('call.join() reports joinSource without putting it on the wire', async () => {
|
|
85
|
+
vi.spyOn(call, 'setup').mockResolvedValue(undefined);
|
|
86
|
+
const doJoin = vi
|
|
87
|
+
.spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin')
|
|
88
|
+
.mockResolvedValue(undefined);
|
|
89
|
+
const withJoinLifecycle = vi.spyOn(
|
|
90
|
+
call.clientEventReporter,
|
|
91
|
+
'withJoinLifecycle',
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await call.join({ joinSource: 'ring-poll-api', ring: true });
|
|
95
|
+
|
|
96
|
+
expect(withJoinLifecycle).toHaveBeenCalledWith(
|
|
97
|
+
call.cid,
|
|
98
|
+
{ joinReason: 'first-attempt', joinSource: 'ring-poll-api' },
|
|
99
|
+
expect.any(Function),
|
|
100
|
+
);
|
|
101
|
+
expect(doJoin).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect('joinSource' in doJoin.mock.calls[0][0]!).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
82
105
|
it('call.join() shares an in-flight join flow', async () => {
|
|
83
106
|
const joinTask = promiseWithResolvers<void>();
|
|
84
107
|
vi.spyOn(call, 'setup').mockResolvedValue(undefined);
|