@stream-io/video-client 1.58.0 → 1.59.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 +22 -0
- package/dist/index.browser.es.js +367 -76
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +378 -74
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +367 -76
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +15 -4
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/utils.d.ts +10 -0
- package/dist/src/devices/CameraManager.d.ts +2 -1
- package/dist/src/devices/DeviceManager.d.ts +1 -1
- package/dist/src/devices/MicrophoneManager.d.ts +2 -1
- package/dist/src/gen/coordinator/index.d.ts +26 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- package/dist/src/sorting/participants.d.ts +13 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/Call.ts +156 -134
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +43 -24
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
- package/src/coordinator/connection/client.ts +3 -2
- package/src/coordinator/connection/utils.ts +28 -0
- package/src/devices/CameraManager.ts +16 -4
- package/src/devices/DeviceManager.ts +7 -4
- package/src/devices/MicrophoneManager.ts +11 -3
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/events/call.ts +6 -3
- package/src/gen/coordinator/index.ts +26 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/loopback.ts +200 -0
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/Subscriber.ts +8 -0
- package/src/rtc/__tests__/Subscriber.test.ts +101 -0
- package/src/sorting/__tests__/presets.test.ts +188 -3
- package/src/sorting/__tests__/sorting.test.ts +22 -1
- package/src/sorting/participants.ts +20 -0
- package/src/sorting/presets.ts +4 -0
- package/src/store/__tests__/CallState.test.ts +2 -2
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { StreamVideoClient } from '../StreamVideoClient';
|
|
5
|
+
import { Call } from '../Call';
|
|
6
|
+
import { CallRingPayload } from './data';
|
|
7
|
+
import { settled, withoutConcurrency } from '../helpers/concurrency';
|
|
8
|
+
import { getCallInitConcurrencyTag } from '../helpers/clientUtils';
|
|
9
|
+
import { CallingState } from '../store';
|
|
10
|
+
import type { StreamVideoEvent } from '../coordinator/connection/types';
|
|
11
|
+
import type {
|
|
12
|
+
CallResponse,
|
|
13
|
+
ConnectedEvent,
|
|
14
|
+
GetCallResponse,
|
|
15
|
+
QueryCallsResponse,
|
|
16
|
+
} from '../gen/coordinator';
|
|
17
|
+
|
|
18
|
+
const apiKey = 'mock-api-key';
|
|
19
|
+
// the receiver of the CallRingPayload fixture
|
|
20
|
+
const userId = 'marcelo';
|
|
21
|
+
|
|
22
|
+
const queryCallsResponse = (call: CallResponse): QueryCallsResponse => ({
|
|
23
|
+
duration: '1ms',
|
|
24
|
+
calls: [{ call, members: CallRingPayload.members, own_capabilities: [] }],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const countListeners = (client: StreamVideoClient) =>
|
|
28
|
+
Object.values(client.streamClient.listeners).reduce(
|
|
29
|
+
(count, listeners) => count + (listeners?.length ?? 0),
|
|
30
|
+
0,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
describe('StreamVideoClient re-watching calls on reconnect', () => {
|
|
34
|
+
let client: StreamVideoClient;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
client = new StreamVideoClient(apiKey, {
|
|
38
|
+
// tests run in node, so we have to fake being in browser env
|
|
39
|
+
browser: true,
|
|
40
|
+
});
|
|
41
|
+
client.streamClient.connectUser = vi.fn().mockResolvedValue({
|
|
42
|
+
me: { id: userId },
|
|
43
|
+
} as ConnectedEvent);
|
|
44
|
+
await client.connectUser({ id: userId }, 'mock-token');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
for (const call of client.state.calls) {
|
|
49
|
+
call['cancelAutoDrop']();
|
|
50
|
+
}
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const setupRingingCall = async () => {
|
|
55
|
+
vi.spyOn(client.streamClient, 'get').mockResolvedValue({
|
|
56
|
+
duration: '1ms',
|
|
57
|
+
call: CallRingPayload.call,
|
|
58
|
+
members: CallRingPayload.members,
|
|
59
|
+
own_capabilities: [],
|
|
60
|
+
} as GetCallResponse);
|
|
61
|
+
|
|
62
|
+
client.streamClient.dispatchEvent(CallRingPayload as StreamVideoEvent);
|
|
63
|
+
await settled(getCallInitConcurrencyTag(CallRingPayload.call_cid));
|
|
64
|
+
|
|
65
|
+
const [call] = client.state.calls;
|
|
66
|
+
expect(call).toBeDefined();
|
|
67
|
+
expect(call.watching).toBe(true);
|
|
68
|
+
expect(call.state.callingState).toBe(CallingState.RINGING);
|
|
69
|
+
return call;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const reconnect = () => {
|
|
73
|
+
client.streamClient.dispatchEvent({
|
|
74
|
+
type: 'connection.changed',
|
|
75
|
+
online: true,
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
it('reuses the registered instance and refreshes its state', async () => {
|
|
80
|
+
const call = await setupRingingCall();
|
|
81
|
+
const listenerCountBeforeReconnect = countListeners(client);
|
|
82
|
+
|
|
83
|
+
const post = vi
|
|
84
|
+
.spyOn(client.streamClient, 'post')
|
|
85
|
+
.mockResolvedValue(
|
|
86
|
+
queryCallsResponse({ ...CallRingPayload.call, recording: true }),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
reconnect();
|
|
90
|
+
|
|
91
|
+
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
|
92
|
+
expect(post).toHaveBeenCalledWith('/calls', {
|
|
93
|
+
watch: true,
|
|
94
|
+
filter_conditions: { cid: { $in: [call.cid] } },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// the refreshed state must reach the instance the integrator holds
|
|
98
|
+
await vi.waitFor(() => expect(call.state.recording).toBe(true));
|
|
99
|
+
expect(client.state.calls).toHaveLength(1);
|
|
100
|
+
expect(client.state.calls[0]).toBe(call);
|
|
101
|
+
expect(call.watching).toBe(true);
|
|
102
|
+
|
|
103
|
+
// re-watching must not leak event handlers (one orphaned Call
|
|
104
|
+
// used to register its handlers on every reconnect)
|
|
105
|
+
expect(countListeners(client)).toBe(listenerCountBeforeReconnect);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('drops a ringing call accepted elsewhere while the socket was down', async () => {
|
|
109
|
+
const call = await setupRingingCall();
|
|
110
|
+
const leave = vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
111
|
+
|
|
112
|
+
const session = CallRingPayload.call.session!;
|
|
113
|
+
vi.spyOn(client.streamClient, 'post').mockResolvedValue(
|
|
114
|
+
queryCallsResponse({
|
|
115
|
+
...CallRingPayload.call,
|
|
116
|
+
session: {
|
|
117
|
+
...session,
|
|
118
|
+
accepted_by: { [userId]: '2025-08-14T14:49:00Z' },
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
reconnect();
|
|
124
|
+
|
|
125
|
+
// accepted on another device -> this device should stop ringing
|
|
126
|
+
await vi.waitFor(() => expect(leave).toHaveBeenCalled());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('queryCalls returns an independent instance for registered calls', async () => {
|
|
130
|
+
// e.g. being on a call while watching a dashboard of calls:
|
|
131
|
+
// leaving the joined instance must not silence the dashboard instance
|
|
132
|
+
const joinedCall = await setupRingingCall();
|
|
133
|
+
vi.spyOn(client.streamClient, 'post').mockResolvedValue(
|
|
134
|
+
queryCallsResponse(CallRingPayload.call),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const result = await client.queryCalls({ watch: true });
|
|
138
|
+
const [dashboardCall] = result.calls;
|
|
139
|
+
expect(dashboardCall).not.toBe(joinedCall);
|
|
140
|
+
expect(client.state.calls[0]).toBe(joinedCall);
|
|
141
|
+
|
|
142
|
+
await joinedCall.leave({ reject: false });
|
|
143
|
+
|
|
144
|
+
client.streamClient.dispatchEvent({
|
|
145
|
+
type: 'call.updated',
|
|
146
|
+
call_cid: joinedCall.cid,
|
|
147
|
+
created_at: '2025-08-14T14:50:00Z',
|
|
148
|
+
call: { ...CallRingPayload.call, recording: true },
|
|
149
|
+
} as StreamVideoEvent);
|
|
150
|
+
|
|
151
|
+
await vi.waitFor(() => expect(dashboardCall.state.recording).toBe(true));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('creates and registers a new instance for unknown cids', async () => {
|
|
155
|
+
vi.spyOn(client.streamClient, 'post').mockResolvedValue(
|
|
156
|
+
queryCallsResponse(CallRingPayload.call),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const result = await client.queryCalls({ watch: true });
|
|
160
|
+
|
|
161
|
+
expect(result.calls).toHaveLength(1);
|
|
162
|
+
const [call] = result.calls;
|
|
163
|
+
expect(call).toBeInstanceOf(Call);
|
|
164
|
+
expect(call.watching).toBe(true);
|
|
165
|
+
expect(client.state.calls[0]).toBe(call);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does not resurrect a call that leaves during a re-watch', async () => {
|
|
169
|
+
const listenersBeforeRing = countListeners(client);
|
|
170
|
+
const call = await setupRingingCall();
|
|
171
|
+
const post = vi
|
|
172
|
+
.spyOn(client.streamClient, 'post')
|
|
173
|
+
.mockResolvedValue(queryCallsResponse(CallRingPayload.call));
|
|
174
|
+
|
|
175
|
+
// hold the call's join/leave queue so leave() is still in flight
|
|
176
|
+
// while the re-watch response is being processed
|
|
177
|
+
let releaseGate!: () => void;
|
|
178
|
+
const gate = new Promise<void>((resolve) => (releaseGate = resolve));
|
|
179
|
+
withoutConcurrency(call['joinLeaveConcurrencyTag'], () => gate);
|
|
180
|
+
const leavePromise = call.leave({ reject: false });
|
|
181
|
+
|
|
182
|
+
reconnect();
|
|
183
|
+
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
|
184
|
+
await Promise.resolve();
|
|
185
|
+
|
|
186
|
+
releaseGate();
|
|
187
|
+
await leavePromise;
|
|
188
|
+
|
|
189
|
+
// the left call must stay dead: not re-registered, not re-initialized,
|
|
190
|
+
// and all of its event handlers released
|
|
191
|
+
expect(client.state.calls).toHaveLength(0);
|
|
192
|
+
expect(call.state.callingState).toBe(CallingState.LEFT);
|
|
193
|
+
expect(countListeners(client)).toBe(listenersBeforeRing);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('creates a fresh instance when the previous one has left', async () => {
|
|
197
|
+
const call = await setupRingingCall();
|
|
198
|
+
const post = vi
|
|
199
|
+
.spyOn(client.streamClient, 'post')
|
|
200
|
+
.mockResolvedValue(queryCallsResponse(CallRingPayload.call));
|
|
201
|
+
|
|
202
|
+
// leaving unregisters the call from the client store
|
|
203
|
+
await call.leave({ reject: false });
|
|
204
|
+
expect(client.state.calls).toHaveLength(0);
|
|
205
|
+
|
|
206
|
+
const result = await client.queryCalls({ watch: true });
|
|
207
|
+
expect(post).toHaveBeenCalled();
|
|
208
|
+
expect(result.calls).toHaveLength(1);
|
|
209
|
+
expect(result.calls[0]).not.toBe(call);
|
|
210
|
+
expect(client.state.calls[0]).toBe(result.calls[0]);
|
|
211
|
+
expect(result.calls[0].watching).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -17,7 +17,14 @@ const secret = process.env.STREAM_SECRET!;
|
|
|
17
17
|
|
|
18
18
|
describe('StreamVideoClient Ringing', () => {
|
|
19
19
|
const serverClient = new StreamClient(apiKey, secret);
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
// user ids are generated per test: CI runs share a single Stream app, so
|
|
22
|
+
// fixed ids let a concurrently running job ring these users and satisfy the
|
|
23
|
+
// assertions in this file with an unrelated call.
|
|
24
|
+
let oliverId: string;
|
|
25
|
+
let sachaId: string;
|
|
26
|
+
let marceloId: string;
|
|
27
|
+
let testUserIds: string[];
|
|
21
28
|
|
|
22
29
|
let oliverClient: StreamVideoClient;
|
|
23
30
|
let sachaClient: StreamVideoClient;
|
|
@@ -45,12 +52,26 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
45
52
|
]);
|
|
46
53
|
};
|
|
47
54
|
|
|
55
|
+
const leaveAllCalls = async () => {
|
|
56
|
+
await Promise.all(
|
|
57
|
+
[oliverClient, sachaClient, marceloClient].flatMap((client) =>
|
|
58
|
+
client.state.calls.map((call) => call.leave().catch(() => {})),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
48
63
|
beforeEach(async () => {
|
|
64
|
+
const suffix = crypto.randomUUID();
|
|
65
|
+
oliverId = `oliver-${suffix}`;
|
|
66
|
+
sachaId = `sacha-${suffix}`;
|
|
67
|
+
marceloId = `marcelo-${suffix}`;
|
|
68
|
+
testUserIds = [oliverId, sachaId, marceloId];
|
|
49
69
|
[oliverClient, sachaClient, marceloClient] =
|
|
50
70
|
await createClients(testUserIds);
|
|
51
71
|
});
|
|
52
72
|
|
|
53
73
|
afterEach(async () => {
|
|
74
|
+
await leaveAllCalls();
|
|
54
75
|
await disconnectClients();
|
|
55
76
|
});
|
|
56
77
|
|
|
@@ -66,11 +87,11 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
66
87
|
await call.create({
|
|
67
88
|
ring: true,
|
|
68
89
|
data: {
|
|
69
|
-
created_by_id:
|
|
90
|
+
created_by_id: oliverId,
|
|
70
91
|
members: [
|
|
71
|
-
{ user_id:
|
|
72
|
-
{ user_id:
|
|
73
|
-
{ user_id:
|
|
92
|
+
{ user_id: oliverId },
|
|
93
|
+
{ user_id: sachaId },
|
|
94
|
+
{ user_id: marceloId },
|
|
74
95
|
],
|
|
75
96
|
},
|
|
76
97
|
});
|
|
@@ -103,9 +124,9 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
103
124
|
ring: true,
|
|
104
125
|
data: {
|
|
105
126
|
members: [
|
|
106
|
-
{ user_id:
|
|
107
|
-
{ user_id:
|
|
108
|
-
{ user_id:
|
|
127
|
+
{ user_id: oliverId },
|
|
128
|
+
{ user_id: sachaId },
|
|
129
|
+
{ user_id: marceloId },
|
|
109
130
|
],
|
|
110
131
|
},
|
|
111
132
|
});
|
|
@@ -135,9 +156,9 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
135
156
|
ring: false, // don't ring all members by default
|
|
136
157
|
data: {
|
|
137
158
|
members: [
|
|
138
|
-
{ user_id:
|
|
139
|
-
{ user_id:
|
|
140
|
-
{ user_id:
|
|
159
|
+
{ user_id: oliverId },
|
|
160
|
+
{ user_id: sachaId },
|
|
161
|
+
{ user_id: marceloId },
|
|
141
162
|
],
|
|
142
163
|
},
|
|
143
164
|
});
|
|
@@ -153,7 +174,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
153
174
|
// oliver is calling sacha. only sacha should get a ring event
|
|
154
175
|
const sachaIndividualRing = expectEvent(sachaClient, 'call.ring');
|
|
155
176
|
const marceloIndividualRing = expectEvent(marceloClient, 'call.ring');
|
|
156
|
-
await oliverCall.ring({ members_ids: [
|
|
177
|
+
await oliverCall.ring({ members_ids: [sachaId] });
|
|
157
178
|
await expect(sachaIndividualRing).resolves.toHaveProperty(
|
|
158
179
|
'call.cid',
|
|
159
180
|
oliverCall.cid,
|
|
@@ -166,7 +187,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
166
187
|
// sacha is calling marcelo. only marcelo should get a ring event
|
|
167
188
|
const oliverIndividualRing = expectEvent(oliverClient, 'call.ring');
|
|
168
189
|
const marceloIndividualRing2 = expectEvent(marceloClient, 'call.ring');
|
|
169
|
-
await sachaCall.ring({ members_ids: [
|
|
190
|
+
await sachaCall.ring({ members_ids: [marceloId] });
|
|
170
191
|
await expect(marceloIndividualRing2).resolves.toHaveProperty(
|
|
171
192
|
'call.cid',
|
|
172
193
|
sachaCall.cid,
|
|
@@ -232,8 +253,8 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
232
253
|
const serverCall = serverClient.video.call('default', callId);
|
|
233
254
|
await serverCall.create({
|
|
234
255
|
data: {
|
|
235
|
-
created_by_id:
|
|
236
|
-
members: [{ user_id:
|
|
256
|
+
created_by_id: oliverId,
|
|
257
|
+
members: [{ user_id: oliverId }, { user_id: sachaId }],
|
|
237
258
|
},
|
|
238
259
|
});
|
|
239
260
|
|
|
@@ -257,7 +278,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
257
278
|
await oliverCall.create({
|
|
258
279
|
ring: true,
|
|
259
280
|
data: {
|
|
260
|
-
members: [{ user_id:
|
|
281
|
+
members: [{ user_id: oliverId }, { user_id: sachaId }],
|
|
261
282
|
},
|
|
262
283
|
});
|
|
263
284
|
|
|
@@ -275,6 +296,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
275
296
|
};
|
|
276
297
|
|
|
277
298
|
it('should reject the call when the caller is busy', async () => {
|
|
299
|
+
await disconnectClients();
|
|
278
300
|
[oliverClient, sachaClient, marceloClient] = await createClients(
|
|
279
301
|
testUserIds,
|
|
280
302
|
{
|
|
@@ -292,7 +314,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
292
314
|
await marceloCall.create({
|
|
293
315
|
ring: true,
|
|
294
316
|
data: {
|
|
295
|
-
members: [{ user_id:
|
|
317
|
+
members: [{ user_id: marceloId }, { user_id: oliverId }],
|
|
296
318
|
},
|
|
297
319
|
});
|
|
298
320
|
|
|
@@ -301,11 +323,10 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
301
323
|
marceloCall.cid,
|
|
302
324
|
);
|
|
303
325
|
expect(oliverClient.state.calls.length).toBe(1);
|
|
304
|
-
|
|
305
|
-
await disconnectClients();
|
|
306
326
|
});
|
|
307
327
|
|
|
308
328
|
it('should reject the call when the callee is busy', async () => {
|
|
329
|
+
await disconnectClients();
|
|
309
330
|
[oliverClient, sachaClient, marceloClient] = await createClients(
|
|
310
331
|
testUserIds,
|
|
311
332
|
{
|
|
@@ -323,7 +344,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
323
344
|
await marceloCall.create({
|
|
324
345
|
ring: true,
|
|
325
346
|
data: {
|
|
326
|
-
members: [{ user_id:
|
|
347
|
+
members: [{ user_id: marceloId }, { user_id: sachaId }],
|
|
327
348
|
},
|
|
328
349
|
});
|
|
329
350
|
|
|
@@ -332,8 +353,6 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
332
353
|
marceloCall.cid,
|
|
333
354
|
);
|
|
334
355
|
expect(sachaClient.state.calls.length).toBe(1);
|
|
335
|
-
|
|
336
|
-
await disconnectClients();
|
|
337
356
|
});
|
|
338
357
|
|
|
339
358
|
it('should allow multiple simultaneous calls to the same caller when rejectCallWhenBusy is false', async () => {
|
|
@@ -347,7 +366,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
347
366
|
await marceloCall.create({
|
|
348
367
|
ring: true,
|
|
349
368
|
data: {
|
|
350
|
-
members: [{ user_id:
|
|
369
|
+
members: [{ user_id: marceloId }, { user_id: oliverId }],
|
|
351
370
|
},
|
|
352
371
|
});
|
|
353
372
|
|
|
@@ -368,7 +387,7 @@ describe('StreamVideoClient Ringing', () => {
|
|
|
368
387
|
await marceloCall.create({
|
|
369
388
|
ring: true,
|
|
370
389
|
data: {
|
|
371
|
-
members: [{ user_id:
|
|
390
|
+
members: [{ user_id: marceloId }, { user_id: sachaId }],
|
|
372
391
|
},
|
|
373
392
|
});
|
|
374
393
|
|
|
@@ -277,7 +277,7 @@ describe('StreamVideoClient.connectUser retries', () => {
|
|
|
277
277
|
});
|
|
278
278
|
|
|
279
279
|
describe('StreamVideoClient.watchCalls retries', () => {
|
|
280
|
-
it('should retry
|
|
280
|
+
it('should retry the re-watch request up to 3 times after reconnecting', async () => {
|
|
281
281
|
vi.useFakeTimers();
|
|
282
282
|
const client = new StreamVideoClient(apiKey, {
|
|
283
283
|
browser: true,
|
|
@@ -290,8 +290,8 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
290
290
|
const call = client.call('default', generateUUIDv4());
|
|
291
291
|
await call.getOrCreate();
|
|
292
292
|
|
|
293
|
-
const
|
|
294
|
-
.spyOn(client, '
|
|
293
|
+
const doQueryCalls = vi
|
|
294
|
+
.spyOn(client as any, 'doQueryCalls')
|
|
295
295
|
.mockRejectedValueOnce(new Error('fail 1'))
|
|
296
296
|
.mockRejectedValueOnce(new Error('fail 2'))
|
|
297
297
|
.mockResolvedValue({
|
|
@@ -306,7 +306,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
306
306
|
|
|
307
307
|
await vi.runAllTimersAsync();
|
|
308
308
|
|
|
309
|
-
expect(
|
|
309
|
+
expect(doQueryCalls).toHaveBeenCalledTimes(3);
|
|
310
310
|
|
|
311
311
|
await client.disconnectUser();
|
|
312
312
|
|
|
@@ -327,8 +327,8 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
327
327
|
const call = client.call('default', generateUUIDv4());
|
|
328
328
|
await call.getOrCreate();
|
|
329
329
|
|
|
330
|
-
const
|
|
331
|
-
.spyOn(client, '
|
|
330
|
+
const doQueryCalls = vi
|
|
331
|
+
.spyOn(client as any, 'doQueryCalls')
|
|
332
332
|
.mockRejectedValueOnce(new Error('fail 1'))
|
|
333
333
|
.mockRejectedValueOnce(new Error('fail 2'))
|
|
334
334
|
.mockRejectedValueOnce(new Error('fail 3'))
|
|
@@ -344,7 +344,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
344
344
|
|
|
345
345
|
await vi.runAllTimersAsync();
|
|
346
346
|
|
|
347
|
-
expect(
|
|
347
|
+
expect(doQueryCalls).toHaveBeenCalledTimes(3);
|
|
348
348
|
|
|
349
349
|
await client.disconnectUser();
|
|
350
350
|
vi.useRealTimers();
|
|
@@ -363,10 +363,12 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
363
363
|
const call = client.call('default', generateUUIDv4());
|
|
364
364
|
await call.getOrCreate();
|
|
365
365
|
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
366
|
+
const doQueryCalls = vi
|
|
367
|
+
.spyOn(client as any, 'doQueryCalls')
|
|
368
|
+
.mockResolvedValue({
|
|
369
|
+
calls: [],
|
|
370
|
+
duration: '0ms',
|
|
371
|
+
});
|
|
370
372
|
|
|
371
373
|
client.streamClient.dispatchEvent({
|
|
372
374
|
type: 'connection.changed',
|
|
@@ -375,7 +377,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
|
|
|
375
377
|
|
|
376
378
|
await vi.runAllTimersAsync();
|
|
377
379
|
|
|
378
|
-
expect(
|
|
380
|
+
expect(doQueryCalls).toHaveBeenCalledTimes(1);
|
|
379
381
|
|
|
380
382
|
await client.disconnectUser();
|
|
381
383
|
vi.useRealTimers();
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { StreamClient } from '../client';
|
|
3
|
+
import { StableWSConnection } from '../connection';
|
|
4
|
+
import { videoLoggerSystem } from '../../../logger';
|
|
5
|
+
import type { StreamVideoEvent } from '../types';
|
|
6
|
+
|
|
7
|
+
const event: StreamVideoEvent = { type: 'network.changed', online: true };
|
|
8
|
+
|
|
9
|
+
describe('event listener isolation', () => {
|
|
10
|
+
let client: StreamClient;
|
|
11
|
+
let sink: ReturnType<typeof vi.fn>;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
sink = vi.fn();
|
|
15
|
+
videoLoggerSystem.configureLoggers({
|
|
16
|
+
coordinator: { sink, level: 'error' },
|
|
17
|
+
});
|
|
18
|
+
client = new StreamClient('key');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
videoLoggerSystem.restoreDefaults();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const errorLogs = () =>
|
|
27
|
+
sink.mock.calls.filter(([level]) => level === 'error');
|
|
28
|
+
|
|
29
|
+
it('keeps dispatching after a listener throws', () => {
|
|
30
|
+
const failure = new Error('listener blew up');
|
|
31
|
+
const first = vi.fn(() => {
|
|
32
|
+
throw failure;
|
|
33
|
+
});
|
|
34
|
+
const second = vi.fn();
|
|
35
|
+
|
|
36
|
+
client.on('network.changed', first);
|
|
37
|
+
client.on('network.changed', second);
|
|
38
|
+
|
|
39
|
+
expect(() => client.dispatchEvent(event)).not.toThrow();
|
|
40
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
41
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('reports a synchronous throw through the logger', () => {
|
|
45
|
+
const failure = new Error('listener blew up');
|
|
46
|
+
client.on('network.changed', () => {
|
|
47
|
+
throw failure;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
client.dispatchEvent(event);
|
|
51
|
+
|
|
52
|
+
expect(errorLogs()).toEqual([
|
|
53
|
+
[
|
|
54
|
+
'error',
|
|
55
|
+
'[coordinator]: Unhandled error in event listener',
|
|
56
|
+
failure,
|
|
57
|
+
event,
|
|
58
|
+
],
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('reports a rejection from an async listener', async () => {
|
|
63
|
+
const failure = new Error('async listener blew up');
|
|
64
|
+
client.on('network.changed', async () => {
|
|
65
|
+
throw failure;
|
|
66
|
+
});
|
|
67
|
+
const next = vi.fn();
|
|
68
|
+
client.on('network.changed', next);
|
|
69
|
+
|
|
70
|
+
client.dispatchEvent(event);
|
|
71
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(errorLogs()).toHaveLength(0);
|
|
73
|
+
|
|
74
|
+
await vi.waitFor(() => expect(errorLogs()).toHaveLength(1));
|
|
75
|
+
expect(errorLogs()[0]).toEqual([
|
|
76
|
+
'error',
|
|
77
|
+
'[coordinator]: Unhandled error in event listener',
|
|
78
|
+
failure,
|
|
79
|
+
event,
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('isolates a faulty `all` listener from type specific listeners', () => {
|
|
84
|
+
const all = vi.fn(() => {
|
|
85
|
+
throw new Error('all listener blew up');
|
|
86
|
+
});
|
|
87
|
+
const typed = vi.fn();
|
|
88
|
+
|
|
89
|
+
client.on('all', all);
|
|
90
|
+
client.on('network.changed', typed);
|
|
91
|
+
|
|
92
|
+
client.dispatchEvent(event);
|
|
93
|
+
|
|
94
|
+
expect(all).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(typed).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(errorLogs()).toHaveLength(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('schedules the connection check even when a listener throws', () => {
|
|
100
|
+
const connection = new StableWSConnection(client);
|
|
101
|
+
const scheduleConnectionCheck = vi
|
|
102
|
+
.spyOn(connection, 'scheduleConnectionCheck')
|
|
103
|
+
.mockImplementation(() => {});
|
|
104
|
+
|
|
105
|
+
client.on('all', () => {
|
|
106
|
+
throw new Error('listener blew up');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
connection.onmessage(connection.wsID, {
|
|
110
|
+
data: JSON.stringify({ type: 'custom' }),
|
|
111
|
+
} as MessageEvent);
|
|
112
|
+
|
|
113
|
+
expect(scheduleConnectionCheck).toHaveBeenCalledTimes(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -10,6 +10,7 @@ import { TokenManager } from './token_manager';
|
|
|
10
10
|
import {
|
|
11
11
|
addConnectionEventListeners,
|
|
12
12
|
generateUUIDv4,
|
|
13
|
+
invokeEventListener,
|
|
13
14
|
isErrorResponse,
|
|
14
15
|
KnownCodes,
|
|
15
16
|
removeConnectionEventListeners,
|
|
@@ -589,12 +590,12 @@ export class StreamClient {
|
|
|
589
590
|
|
|
590
591
|
// call generic listeners
|
|
591
592
|
for (const listener of this.listeners.all || []) {
|
|
592
|
-
listener
|
|
593
|
+
invokeEventListener(listener, event, this.logger);
|
|
593
594
|
}
|
|
594
595
|
|
|
595
596
|
// call type specific listeners
|
|
596
597
|
for (const listener of this.listeners[event.type] || []) {
|
|
597
|
-
listener
|
|
598
|
+
invokeEventListener(listener, event, this.logger);
|
|
598
599
|
}
|
|
599
600
|
};
|
|
600
601
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AxiosResponse } from 'axios';
|
|
2
2
|
import type { APIErrorResponse } from './types';
|
|
3
3
|
import type { ConnectionErrorEvent } from '../../gen/coordinator';
|
|
4
|
+
import type { ScopedLogger } from '../../logger';
|
|
4
5
|
|
|
5
6
|
export const sleep = (m: number) => new Promise((r) => setTimeout(r, m));
|
|
6
7
|
|
|
@@ -118,3 +119,30 @@ export function isCloseEvent(
|
|
|
118
119
|
): res is CloseEvent {
|
|
119
120
|
return (res as CloseEvent).code !== undefined;
|
|
120
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Invokes a single event listener in isolation, so that a faulty listener
|
|
125
|
+
* cannot abort the dispatch loop nor the bookkeeping that follows it.
|
|
126
|
+
*
|
|
127
|
+
* @param listener the listener to invoke.
|
|
128
|
+
* @param event the event to pass to the listener.
|
|
129
|
+
* @param logger the logger to report listener failures with.
|
|
130
|
+
*/
|
|
131
|
+
export const invokeEventListener = <T>(
|
|
132
|
+
listener: (event: T) => unknown,
|
|
133
|
+
event: T,
|
|
134
|
+
logger: ScopedLogger,
|
|
135
|
+
) => {
|
|
136
|
+
try {
|
|
137
|
+
const result = listener(event);
|
|
138
|
+
if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
|
|
139
|
+
Promise.resolve(result).catch((error) => report(logger, error, event));
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
report(logger, error, event);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const report = (logger: ScopedLogger, error: unknown, event: unknown) => {
|
|
147
|
+
logger.error('Unhandled error in event listener', error, event);
|
|
148
|
+
};
|
|
@@ -167,8 +167,13 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
167
167
|
*
|
|
168
168
|
* @param settings the video settings to apply.
|
|
169
169
|
* @param publish whether to publish the stream after applying the settings.
|
|
170
|
+
* @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
|
|
170
171
|
*/
|
|
171
|
-
async apply(
|
|
172
|
+
async apply(
|
|
173
|
+
settings: VideoSettingsResponse,
|
|
174
|
+
publish: boolean,
|
|
175
|
+
forceDisabled: boolean = false,
|
|
176
|
+
): Promise<void> {
|
|
172
177
|
// Wait for any in progress camera operation
|
|
173
178
|
await this.statusChangeSettled();
|
|
174
179
|
await this.selectTargetResolution(settings.target_resolution);
|
|
@@ -186,8 +191,10 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
186
191
|
this.devicePersistence.enabled &&
|
|
187
192
|
permissionState === 'granted'
|
|
188
193
|
) {
|
|
189
|
-
persistedPreferencesApplied =
|
|
190
|
-
|
|
194
|
+
persistedPreferencesApplied = await this.applyPersistedPreferences(
|
|
195
|
+
enabledInCallType,
|
|
196
|
+
forceDisabled,
|
|
197
|
+
);
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
// apply a direction and enable the camera only if in "pristine" state,
|
|
@@ -199,7 +206,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
199
206
|
await this.selectDirection(direction, { enableCamera: false });
|
|
200
207
|
}
|
|
201
208
|
|
|
202
|
-
if (
|
|
209
|
+
if (
|
|
210
|
+
!forceDisabled &&
|
|
211
|
+
canPublish &&
|
|
212
|
+
settings.camera_default_on &&
|
|
213
|
+
enabledInCallType
|
|
214
|
+
) {
|
|
203
215
|
await this.enable();
|
|
204
216
|
}
|
|
205
217
|
}
|