@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,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, StreamVideoWriteableStateStore } 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 clientStore = new StreamVideoWriteableStateStore();
|
|
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
|
+
clientStore,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
clientStore.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
|
+
});
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
3
|
+
import { reconcileRingState } from '../reconcileRingState';
|
|
4
|
+
import { CallingState, StreamVideoWriteableStateStore } from '../../store';
|
|
5
|
+
import {
|
|
6
|
+
CallResponse,
|
|
7
|
+
CallSessionResponse,
|
|
8
|
+
MemberResponse,
|
|
9
|
+
} from '../../gen/coordinator';
|
|
10
|
+
import { Call } from '../../Call';
|
|
11
|
+
import { StreamClient } from '../../coordinator/connection/client';
|
|
12
|
+
import type { JoinSource } from '../../reporting';
|
|
13
|
+
import { ClientEventReporter } from '../../reporting';
|
|
14
|
+
import { settled } from '../../helpers/concurrency';
|
|
15
|
+
|
|
16
|
+
describe('reconcileRingState', () => {
|
|
17
|
+
describe('acceptance', () => {
|
|
18
|
+
it('ignores an acceptance by the current user', async () => {
|
|
19
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
20
|
+
setSession(call, { accepted_by: { m1: timestamp() } });
|
|
21
|
+
|
|
22
|
+
expect(await reconcile(call)).toBe(false);
|
|
23
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('joins the call for the caller once a callee has accepted', async () => {
|
|
27
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
28
|
+
setSession(call, { accepted_by: { m2: timestamp() } });
|
|
29
|
+
|
|
30
|
+
expect(await reconcile(call)).toBe(true);
|
|
31
|
+
expect(call.join).toHaveBeenCalledWith({ joinSource: 'ring-ws' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('reports the poller as the source when the poll found the acceptance', async () => {
|
|
35
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
36
|
+
setSession(call, { accepted_by: { m2: timestamp() } });
|
|
37
|
+
|
|
38
|
+
expect(await reconcile(call, 'ring-poll-api')).toBe(true);
|
|
39
|
+
expect(call.join).toHaveBeenCalledWith({ joinSource: 'ring-poll-api' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is not terminal when the join fails, so a retry can follow', async () => {
|
|
43
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
44
|
+
setSession(call, { accepted_by: { m2: timestamp() } });
|
|
45
|
+
vi.mocked(call.join).mockRejectedValueOnce(new Error('transient'));
|
|
46
|
+
|
|
47
|
+
expect(await reconcile(call)).toBe(false);
|
|
48
|
+
expect(call.join).toHaveBeenCalled();
|
|
49
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not join another callee when someone else accepts', async () => {
|
|
53
|
+
const call = ringingCall({ currentUserId: 'm2', createdById: 'm0' });
|
|
54
|
+
setSession(call, { accepted_by: { m1: timestamp() } });
|
|
55
|
+
|
|
56
|
+
expect(await reconcile(call)).toBe(false);
|
|
57
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('rejection', () => {
|
|
62
|
+
it('cancels the call once every callee has rejected', async () => {
|
|
63
|
+
const call = ringingCall({
|
|
64
|
+
currentUserId: 'm1',
|
|
65
|
+
createdById: 'm1',
|
|
66
|
+
members: ['m1', 'm2', 'm3'],
|
|
67
|
+
});
|
|
68
|
+
setSession(call, {
|
|
69
|
+
rejected_by: { m2: timestamp(), m3: timestamp() },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(await reconcile(call)).toBe(true);
|
|
73
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
74
|
+
reject: true,
|
|
75
|
+
reason: 'cancel',
|
|
76
|
+
message: 'ring: everyone rejected',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is not terminal when leaving fails, so a retry can follow', async () => {
|
|
81
|
+
const call = ringingCall({
|
|
82
|
+
currentUserId: 'm1',
|
|
83
|
+
createdById: 'm1',
|
|
84
|
+
members: ['m1', 'm2'],
|
|
85
|
+
});
|
|
86
|
+
setSession(call, { rejected_by: { m2: timestamp() } });
|
|
87
|
+
vi.mocked(call.leave).mockRejectedValueOnce(new Error('transient'));
|
|
88
|
+
|
|
89
|
+
expect(await reconcile(call)).toBe(false);
|
|
90
|
+
expect(call.leave).toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps ringing while only one callee has rejected', async () => {
|
|
94
|
+
const call = ringingCall({
|
|
95
|
+
currentUserId: 'm0',
|
|
96
|
+
createdById: 'm0',
|
|
97
|
+
members: ['m0', 'm1', 'm2'],
|
|
98
|
+
});
|
|
99
|
+
setSession(call, { rejected_by: { m2: timestamp() } });
|
|
100
|
+
|
|
101
|
+
expect(await reconcile(call)).toBe(false);
|
|
102
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('leaves a callee once the caller has cancelled', async () => {
|
|
106
|
+
const call = ringingCall({
|
|
107
|
+
currentUserId: 'm1',
|
|
108
|
+
createdById: 'm0',
|
|
109
|
+
members: ['m0', 'm1', 'm2'],
|
|
110
|
+
});
|
|
111
|
+
setSession(call, { rejected_by: { m0: timestamp() } });
|
|
112
|
+
|
|
113
|
+
expect(await reconcile(call)).toBe(true);
|
|
114
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
115
|
+
reason: 'ended',
|
|
116
|
+
message: 'ring: creator rejected',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keeps a callee ringing while another callee rejects', async () => {
|
|
121
|
+
const call = ringingCall({
|
|
122
|
+
currentUserId: 'm1',
|
|
123
|
+
createdById: 'm0',
|
|
124
|
+
members: ['m0', 'm1', 'm2'],
|
|
125
|
+
});
|
|
126
|
+
setSession(call, { rejected_by: { m2: timestamp() } });
|
|
127
|
+
|
|
128
|
+
expect(await reconcile(call)).toBe(false);
|
|
129
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('missed', () => {
|
|
134
|
+
it('keeps ringing when every callee is marked missed before auto-cancel', async () => {
|
|
135
|
+
const call = ringingCall({
|
|
136
|
+
currentUserId: 'm1',
|
|
137
|
+
createdById: 'm1',
|
|
138
|
+
members: ['m1', 'm2', 'm3'],
|
|
139
|
+
});
|
|
140
|
+
setSession(call, {
|
|
141
|
+
missed_by: { m2: timestamp(), m3: timestamp() },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(await reconcile(call)).toBe(false);
|
|
145
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps ringing when callees are split between rejected and missed', async () => {
|
|
149
|
+
const call = ringingCall({
|
|
150
|
+
currentUserId: 'm1',
|
|
151
|
+
createdById: 'm1',
|
|
152
|
+
members: ['m1', 'm2', 'm3'],
|
|
153
|
+
});
|
|
154
|
+
setSession(call, {
|
|
155
|
+
rejected_by: { m2: timestamp() },
|
|
156
|
+
missed_by: { m3: timestamp() },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(await reconcile(call)).toBe(false);
|
|
160
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('keeps ringing while one callee can still accept', async () => {
|
|
164
|
+
const call = ringingCall({
|
|
165
|
+
currentUserId: 'm1',
|
|
166
|
+
createdById: 'm1',
|
|
167
|
+
members: ['m1', 'm2', 'm3'],
|
|
168
|
+
});
|
|
169
|
+
setSession(call, { missed_by: { m2: timestamp() } });
|
|
170
|
+
|
|
171
|
+
expect(await reconcile(call)).toBe(false);
|
|
172
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('ended call', () => {
|
|
177
|
+
it('leaves without rejecting, even when the call was accepted', async () => {
|
|
178
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
179
|
+
setSession(call, {
|
|
180
|
+
accepted_by: { m2: timestamp() },
|
|
181
|
+
ended_at: timestamp(),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(await reconcile(call)).toBe(true);
|
|
185
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
186
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
187
|
+
reject: false,
|
|
188
|
+
reason: 'ended',
|
|
189
|
+
message: 'ring: call ended',
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('leaves when the call itself has ended', async () => {
|
|
194
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
195
|
+
setSession(call, {});
|
|
196
|
+
call.state.setEndedAt(new Date());
|
|
197
|
+
|
|
198
|
+
expect(await reconcile(call)).toBe(true);
|
|
199
|
+
expect(call.leave).toHaveBeenCalledWith({
|
|
200
|
+
reject: false,
|
|
201
|
+
reason: 'ended',
|
|
202
|
+
message: 'ring: call ended',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('is terminal once the call is no longer ringing', async () => {
|
|
208
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
209
|
+
setSession(call, { accepted_by: { m2: timestamp() } });
|
|
210
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
211
|
+
|
|
212
|
+
expect(await reconcile(call)).toBe(true);
|
|
213
|
+
expect(call.join).not.toHaveBeenCalled();
|
|
214
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Pins the ordering the state-driven reconciler depends on: `Call.setup`
|
|
218
|
+
// registers `updateFromEvent` as an `all` listener, and `dispatchEvent`
|
|
219
|
+
// drains those before the typed ring handlers. Reconciling from state would
|
|
220
|
+
// silently read the previous session if either side changed.
|
|
221
|
+
it('sees the event data on the state by the time a ring handler runs', async () => {
|
|
222
|
+
const call = ringingCall({ currentUserId: 'm1', createdById: 'm1' });
|
|
223
|
+
await call.setup();
|
|
224
|
+
call.state.setCallingState(CallingState.RINGING);
|
|
225
|
+
|
|
226
|
+
call.streamClient.dispatchEvent(
|
|
227
|
+
fromPartial({
|
|
228
|
+
type: 'call.accepted',
|
|
229
|
+
call_cid: call.cid,
|
|
230
|
+
created_at: new Date().toISOString(),
|
|
231
|
+
user: { id: 'm2' },
|
|
232
|
+
call: {
|
|
233
|
+
...callResponse('m1'),
|
|
234
|
+
session: {
|
|
235
|
+
id: 'session-1',
|
|
236
|
+
accepted_by: { m2: timestamp() },
|
|
237
|
+
rejected_by: {},
|
|
238
|
+
missed_by: {},
|
|
239
|
+
participants: [],
|
|
240
|
+
participants_count_by_role: {},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
await settled(call['joinLeaveConcurrencyTag']);
|
|
246
|
+
|
|
247
|
+
expect(call.join).toHaveBeenCalledWith({ joinSource: 'ring-ws' });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('keeps ringing when the call has no members yet', async () => {
|
|
251
|
+
const call = ringingCall({
|
|
252
|
+
currentUserId: 'm1',
|
|
253
|
+
createdById: 'm1',
|
|
254
|
+
members: [],
|
|
255
|
+
});
|
|
256
|
+
setSession(call, {});
|
|
257
|
+
|
|
258
|
+
expect(await reconcile(call)).toBe(false);
|
|
259
|
+
expect(call.leave).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// the source only matters for the caller's join, so default it and let the
|
|
264
|
+
// tests that assert on it pass their own
|
|
265
|
+
const reconcile = (call: Call, joinSource: JoinSource = 'ring-ws') =>
|
|
266
|
+
reconcileRingState(call, joinSource);
|
|
267
|
+
|
|
268
|
+
const timestamp = () => new Date().toISOString();
|
|
269
|
+
|
|
270
|
+
const callResponse = (createdById: string) =>
|
|
271
|
+
fromPartial<CallResponse>({
|
|
272
|
+
id: '12345',
|
|
273
|
+
type: 'development',
|
|
274
|
+
cid: 'development:12345',
|
|
275
|
+
created_by: { id: createdById },
|
|
276
|
+
blocked_user_ids: [],
|
|
277
|
+
egress: {},
|
|
278
|
+
settings: {
|
|
279
|
+
ring: {
|
|
280
|
+
auto_cancel_timeout_ms: 30_000,
|
|
281
|
+
incoming_call_timeout_ms: 30_000,
|
|
282
|
+
missed_call_timeout_ms: 30_000,
|
|
283
|
+
},
|
|
284
|
+
screensharing: { target_resolution: undefined },
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const setSession = (call: Call, session: Partial<CallSessionResponse>) => {
|
|
289
|
+
call.state['sessionSubject'].next(
|
|
290
|
+
fromPartial({
|
|
291
|
+
accepted_by: {},
|
|
292
|
+
rejected_by: {},
|
|
293
|
+
missed_by: {},
|
|
294
|
+
...session,
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const ringingCall = ({
|
|
300
|
+
currentUserId,
|
|
301
|
+
createdById,
|
|
302
|
+
members = [currentUserId, 'm2'],
|
|
303
|
+
}: {
|
|
304
|
+
currentUserId: string;
|
|
305
|
+
createdById: string;
|
|
306
|
+
members?: string[];
|
|
307
|
+
}) => {
|
|
308
|
+
const store = new StreamVideoWriteableStateStore();
|
|
309
|
+
store.setConnectedUser(fromPartial({ id: currentUserId }));
|
|
310
|
+
const streamClient = new StreamClient('api-key');
|
|
311
|
+
const call = new Call({
|
|
312
|
+
type: 'development',
|
|
313
|
+
id: '12345',
|
|
314
|
+
clientStore: store,
|
|
315
|
+
streamClient,
|
|
316
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
317
|
+
ringing: true,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
call.state.updateFromCallResponse(callResponse(createdById));
|
|
321
|
+
call.state.setMembers(
|
|
322
|
+
members.map((userId) => fromPartial<MemberResponse>({ user_id: userId })),
|
|
323
|
+
);
|
|
324
|
+
call.state.setCallingState(CallingState.RINGING);
|
|
325
|
+
|
|
326
|
+
vi.spyOn(call, 'join').mockResolvedValue(undefined);
|
|
327
|
+
vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
328
|
+
|
|
329
|
+
return call;
|
|
330
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
3
|
+
import {
|
|
4
|
+
type OwnRingOutcomeInput,
|
|
5
|
+
resolveOwnRingOutcome,
|
|
6
|
+
} from '../resolveOwnRingOutcome';
|
|
7
|
+
import { CallingState } from '../../store';
|
|
8
|
+
import { CallSessionResponse } from '../../gen/coordinator';
|
|
9
|
+
|
|
10
|
+
const ME = 'm1';
|
|
11
|
+
const timestamp = () => new Date().toISOString();
|
|
12
|
+
|
|
13
|
+
describe('resolveOwnRingOutcome', () => {
|
|
14
|
+
const resolve = (overrides: Partial<OwnRingOutcomeInput> = {}) =>
|
|
15
|
+
resolveOwnRingOutcome({
|
|
16
|
+
currentUserId: ME,
|
|
17
|
+
callingState: CallingState.RINGING,
|
|
18
|
+
...overrides,
|
|
19
|
+
session: fromPartial<CallSessionResponse>({
|
|
20
|
+
accepted_by: {},
|
|
21
|
+
rejected_by: {},
|
|
22
|
+
missed_by: {},
|
|
23
|
+
...overrides.session,
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reports nothing while nobody has acted', () => {
|
|
28
|
+
expect(resolve()).toEqual({ settledByMe: false });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports nothing for another user accepting or rejecting', () => {
|
|
32
|
+
const outcome = resolve({
|
|
33
|
+
session: fromPartial({
|
|
34
|
+
accepted_by: { m2: timestamp() },
|
|
35
|
+
rejected_by: { m3: timestamp() },
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(outcome).toEqual({ settledByMe: false });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('leaves when the ring was answered on another device', () => {
|
|
43
|
+
const outcome = resolve({
|
|
44
|
+
session: fromPartial({ accepted_by: { [ME]: timestamp() } }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(outcome).toEqual({
|
|
48
|
+
settledByMe: true,
|
|
49
|
+
leaveReason: 'answeredElsewhere',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not leave when this device is the one that accepted', () => {
|
|
54
|
+
const outcome = resolve({
|
|
55
|
+
callingState: CallingState.JOINING,
|
|
56
|
+
session: fromPartial({ accepted_by: { [ME]: timestamp() } }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// the drop no longer has to fire, but this device is joining, not leaving
|
|
60
|
+
expect(outcome).toEqual({ settledByMe: true, leaveReason: undefined });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('leaves when the current user rejected', () => {
|
|
64
|
+
const outcome = resolve({
|
|
65
|
+
session: fromPartial({ rejected_by: { [ME]: timestamp() } }),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(outcome).toEqual({ settledByMe: true, leaveReason: 'rejected' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('prefers the answered-elsewhere reason over a rejection', () => {
|
|
72
|
+
const outcome = resolve({
|
|
73
|
+
session: fromPartial({
|
|
74
|
+
accepted_by: { [ME]: timestamp() },
|
|
75
|
+
rejected_by: { [ME]: timestamp() },
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(outcome).toEqual({
|
|
80
|
+
settledByMe: true,
|
|
81
|
+
leaveReason: 'answeredElsewhere',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reports nothing without a connected user', () => {
|
|
86
|
+
const outcome = resolve({
|
|
87
|
+
currentUserId: undefined,
|
|
88
|
+
session: fromPartial({ rejected_by: { [ME]: timestamp() } }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(outcome).toEqual({ settledByMe: false });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('reports nothing without a session', () => {
|
|
95
|
+
expect(
|
|
96
|
+
resolveOwnRingOutcome({
|
|
97
|
+
session: undefined,
|
|
98
|
+
currentUserId: ME,
|
|
99
|
+
callingState: CallingState.RINGING,
|
|
100
|
+
}),
|
|
101
|
+
).toEqual({ settledByMe: false });
|
|
102
|
+
});
|
|
103
|
+
});
|