@stream-io/video-client 1.43.0 → 1.44.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +206 -59
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +205 -58
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +206 -59
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/StreamVideoClient.d.ts +2 -8
  9. package/dist/src/coordinator/connection/types.d.ts +5 -0
  10. package/dist/src/devices/CameraManager.d.ts +7 -2
  11. package/dist/src/devices/DeviceManager.d.ts +7 -15
  12. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  13. package/dist/src/devices/SpeakerManager.d.ts +6 -1
  14. package/dist/src/devices/devicePersistence.d.ts +27 -0
  15. package/dist/src/helpers/clientUtils.d.ts +1 -1
  16. package/dist/src/permissions/PermissionsContext.d.ts +1 -1
  17. package/dist/src/types.d.ts +38 -2
  18. package/package.json +1 -1
  19. package/src/Call.ts +5 -3
  20. package/src/StreamVideoClient.ts +1 -9
  21. package/src/coordinator/connection/types.ts +6 -0
  22. package/src/devices/CameraManager.ts +31 -11
  23. package/src/devices/DeviceManager.ts +113 -31
  24. package/src/devices/MicrophoneManager.ts +26 -8
  25. package/src/devices/ScreenShareManager.ts +7 -1
  26. package/src/devices/SpeakerManager.ts +62 -18
  27. package/src/devices/__tests__/CameraManager.test.ts +184 -21
  28. package/src/devices/__tests__/DeviceManager.test.ts +184 -2
  29. package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -0
  30. package/src/devices/__tests__/MicrophoneManager.test.ts +146 -2
  31. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -0
  32. package/src/devices/__tests__/ScreenShareManager.test.ts +2 -0
  33. package/src/devices/__tests__/SpeakerManager.test.ts +90 -0
  34. package/src/devices/__tests__/devicePersistence.test.ts +142 -0
  35. package/src/devices/__tests__/devices.test.ts +390 -0
  36. package/src/devices/__tests__/mediaStreamTestHelpers.ts +58 -0
  37. package/src/devices/__tests__/mocks.ts +35 -0
  38. package/src/devices/devicePersistence.ts +106 -0
  39. package/src/devices/devices.ts +3 -3
  40. package/src/helpers/__tests__/DynascaleManager.test.ts +3 -1
  41. package/src/helpers/clientUtils.ts +1 -1
  42. package/src/permissions/PermissionsContext.ts +1 -0
  43. package/src/sorting/presets.ts +1 -1
  44. package/src/store/CallState.ts +1 -1
  45. package/src/types.ts +49 -2
@@ -0,0 +1,142 @@
1
+ /* @vitest-environment happy-dom */
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import {
4
+ defaultDeviceId,
5
+ normalize,
6
+ readPreferences,
7
+ writePreferences,
8
+ } from '../devicePersistence';
9
+ import { createLocalStorageMock, LocalStorageMock } from './mocks';
10
+
11
+ const storageKey = '@test/device-preferences';
12
+
13
+ const createDevice = (
14
+ deviceId: string,
15
+ label: string,
16
+ kind: MediaDeviceKind = 'audioinput',
17
+ ): MediaDeviceInfo =>
18
+ ({
19
+ deviceId,
20
+ label,
21
+ kind,
22
+ groupId: 'group-1',
23
+ }) as MediaDeviceInfo;
24
+
25
+ describe('devicePersistence', () => {
26
+ let localStorageMock: LocalStorageMock;
27
+
28
+ beforeEach(() => {
29
+ localStorageMock = createLocalStorageMock();
30
+ Object.defineProperty(window, 'localStorage', {
31
+ configurable: true,
32
+ value: localStorageMock,
33
+ });
34
+ localStorageMock.clear();
35
+ });
36
+
37
+ afterEach(() => {
38
+ Object.defineProperty(window, 'localStorage', {
39
+ configurable: true,
40
+ value: undefined,
41
+ });
42
+ });
43
+
44
+ it('enables persistence by default when localStorage is available', () => {
45
+ expect(normalize(undefined).enabled).toBe(true);
46
+ });
47
+
48
+ it('disables persistence when explicitly turned off', () => {
49
+ expect(normalize({ enabled: false }).enabled).toBe(false);
50
+ });
51
+
52
+ it('persists device data correctly', () => {
53
+ const device = createDevice('mic-1', 'Mic 1');
54
+
55
+ writePreferences(device, 'microphone', true, storageKey);
56
+
57
+ const preferences = readPreferences(storageKey);
58
+ expect(preferences.microphone).toEqual([
59
+ {
60
+ selectedDeviceId: 'mic-1',
61
+ selectedDeviceLabel: 'Mic 1',
62
+ muted: true,
63
+ },
64
+ ]);
65
+ });
66
+
67
+ it('persists only the three most recent entries per device type', () => {
68
+ writePreferences(
69
+ createDevice('mic-1', 'Mic 1'),
70
+ 'microphone',
71
+ false,
72
+ storageKey,
73
+ );
74
+ writePreferences(
75
+ createDevice('mic-2', 'Mic 2'),
76
+ 'microphone',
77
+ false,
78
+ storageKey,
79
+ );
80
+ writePreferences(
81
+ createDevice('mic-3', 'Mic 3'),
82
+ 'microphone',
83
+ false,
84
+ storageKey,
85
+ );
86
+ writePreferences(
87
+ createDevice('mic-4', 'Mic 4'),
88
+ 'microphone',
89
+ false,
90
+ storageKey,
91
+ );
92
+
93
+ const preferences = readPreferences(storageKey);
94
+ expect(preferences.microphone).toEqual([
95
+ {
96
+ selectedDeviceId: 'mic-4',
97
+ selectedDeviceLabel: 'Mic 4',
98
+ muted: false,
99
+ },
100
+ {
101
+ selectedDeviceId: 'mic-3',
102
+ selectedDeviceLabel: 'Mic 3',
103
+ muted: false,
104
+ },
105
+ {
106
+ selectedDeviceId: 'mic-2',
107
+ selectedDeviceLabel: 'Mic 2',
108
+ muted: false,
109
+ },
110
+ ]);
111
+ });
112
+
113
+ it('loads preferences from storage', () => {
114
+ window.localStorage.setItem(
115
+ storageKey,
116
+ JSON.stringify({
117
+ camera: {
118
+ selectedDeviceId: 'cam-1',
119
+ selectedDeviceLabel: 'Cam 1',
120
+ },
121
+ }),
122
+ );
123
+
124
+ const preferences = readPreferences(storageKey);
125
+ expect(preferences.camera).toEqual({
126
+ selectedDeviceId: 'cam-1',
127
+ selectedDeviceLabel: 'Cam 1',
128
+ });
129
+ });
130
+
131
+ it('uses default device when none is provided', () => {
132
+ writePreferences(undefined, 'speaker', undefined, storageKey);
133
+
134
+ const preferences = readPreferences(storageKey);
135
+ expect(preferences.speaker).toEqual([
136
+ {
137
+ selectedDeviceId: defaultDeviceId,
138
+ selectedDeviceLabel: '',
139
+ },
140
+ ]);
141
+ });
142
+ });
@@ -0,0 +1,390 @@
1
+ /* @vitest-environment happy-dom */
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { firstValueFrom, of, skip } from 'rxjs';
4
+
5
+ const permissionInstances: Array<{
6
+ prompt: ReturnType<typeof vi.fn>;
7
+ }> = [];
8
+
9
+ vi.mock('../BrowserPermission', () => {
10
+ class BrowserPermission {
11
+ prompt = vi.fn(async () => true);
12
+ asObservable = vi.fn(() => of(true));
13
+ asStateObservable = vi.fn(() => of('granted'));
14
+ getIsPromptingObservable = vi.fn(() => of(false));
15
+
16
+ constructor() {
17
+ permissionInstances.push(this);
18
+ }
19
+ }
20
+ return { BrowserPermission };
21
+ });
22
+
23
+ vi.mock('../../helpers/browsers', () => ({
24
+ isSafari: vi.fn(),
25
+ isFirefox: vi.fn(),
26
+ }));
27
+
28
+ type MediaDevicesMock = MediaDevices &
29
+ EventTarget & {
30
+ enumerateDevices: ReturnType<typeof vi.fn>;
31
+ getUserMedia: ReturnType<typeof vi.fn>;
32
+ getDisplayMedia: ReturnType<typeof vi.fn>;
33
+ };
34
+
35
+ const setupMediaDevices = (
36
+ overrides: Partial<MediaDevices> = {},
37
+ ): MediaDevicesMock => {
38
+ const eventTarget = new EventTarget();
39
+ const mediaDevices = Object.assign(eventTarget, {
40
+ enumerateDevices: vi.fn(async () => []),
41
+ getUserMedia: vi.fn(async () => ({}) as MediaStream),
42
+ getDisplayMedia: vi.fn(async () => ({}) as MediaStream),
43
+ ...overrides,
44
+ }) as MediaDevicesMock;
45
+
46
+ Object.defineProperty(globalThis, 'navigator', {
47
+ configurable: true,
48
+ value: { mediaDevices },
49
+ });
50
+
51
+ return mediaDevices;
52
+ };
53
+
54
+ const loadDevicesModule = async () => {
55
+ vi.resetModules();
56
+ return await import('../devices');
57
+ };
58
+
59
+ describe('devices', () => {
60
+ beforeEach(() => {
61
+ permissionInstances.length = 0;
62
+ });
63
+
64
+ afterEach(() => {
65
+ vi.clearAllMocks();
66
+ vi.resetModules();
67
+ });
68
+
69
+ it('checkIfAudioOutputChangeSupported uses audio element when not Safari', async () => {
70
+ setupMediaDevices();
71
+ const { checkIfAudioOutputChangeSupported } = await loadDevicesModule();
72
+ const browsers = await import('../../helpers/browsers');
73
+ vi.mocked(browsers.isSafari).mockReturnValue(false);
74
+ const createElementSpy = vi
75
+ .spyOn(document, 'createElement')
76
+ .mockReturnValue({ setSinkId: vi.fn() } as unknown as HTMLMediaElement);
77
+
78
+ expect(checkIfAudioOutputChangeSupported()).toBe(true);
79
+ expect(createElementSpy).toHaveBeenCalledWith('audio');
80
+ });
81
+
82
+ it('checkIfAudioOutputChangeSupported uses AudioContext in Safari', async () => {
83
+ setupMediaDevices();
84
+ class AudioContextStub {}
85
+ (
86
+ AudioContextStub.prototype as { setSinkId?: () => Promise<void> }
87
+ ).setSinkId = vi.fn();
88
+ Object.defineProperty(globalThis, 'AudioContext', {
89
+ configurable: true,
90
+ value: AudioContextStub as typeof AudioContext,
91
+ });
92
+
93
+ const { checkIfAudioOutputChangeSupported } = await loadDevicesModule();
94
+ const browsers = await import('../../helpers/browsers');
95
+ vi.mocked(browsers.isSafari).mockReturnValue(true);
96
+ expect(checkIfAudioOutputChangeSupported()).toBe(true);
97
+ });
98
+
99
+ it('getAudioDevices prompts and filters devices', async () => {
100
+ const mediaDevices = setupMediaDevices({
101
+ enumerateDevices: vi
102
+ .fn()
103
+ .mockResolvedValueOnce([
104
+ { kind: 'audioinput', label: '', deviceId: 'id-1', groupId: 'g1' },
105
+ {
106
+ kind: 'audioinput',
107
+ label: 'Default device',
108
+ deviceId: 'default',
109
+ groupId: 'g1',
110
+ },
111
+ ])
112
+ .mockResolvedValueOnce([
113
+ {
114
+ kind: 'audioinput',
115
+ label: 'Mic 1',
116
+ deviceId: 'id-1',
117
+ groupId: 'g1',
118
+ },
119
+ {
120
+ kind: 'audioinput',
121
+ label: 'Mic 2',
122
+ deviceId: 'id-2',
123
+ groupId: 'g2',
124
+ },
125
+ ]),
126
+ });
127
+
128
+ const { getAudioDevices } = await loadDevicesModule();
129
+ const devices = await firstValueFrom(getAudioDevices());
130
+
131
+ expect(
132
+ vi.mocked(mediaDevices.enumerateDevices).mock.calls.length,
133
+ ).toBeGreaterThanOrEqual(2);
134
+ expect(permissionInstances[0].prompt).toHaveBeenCalled();
135
+ expect(devices).toEqual([
136
+ { kind: 'audioinput', label: 'Mic 1', deviceId: 'id-1', groupId: 'g1' },
137
+ { kind: 'audioinput', label: 'Mic 2', deviceId: 'id-2', groupId: 'g2' },
138
+ ]);
139
+ });
140
+
141
+ it('getVideoDevices prompts and filters devices', async () => {
142
+ const mediaDevices = setupMediaDevices({
143
+ enumerateDevices: vi
144
+ .fn()
145
+ .mockResolvedValueOnce([
146
+ { kind: 'videoinput', label: '', deviceId: 'id-1', groupId: 'g1' },
147
+ {
148
+ kind: 'videoinput',
149
+ label: 'Default camera',
150
+ deviceId: 'default',
151
+ groupId: 'g1',
152
+ },
153
+ ])
154
+ .mockResolvedValueOnce([
155
+ {
156
+ kind: 'videoinput',
157
+ label: 'Cam 1',
158
+ deviceId: 'id-1',
159
+ groupId: 'g1',
160
+ },
161
+ {
162
+ kind: 'videoinput',
163
+ label: 'Cam 2',
164
+ deviceId: 'id-2',
165
+ groupId: 'g2',
166
+ },
167
+ ]),
168
+ });
169
+
170
+ const { getVideoDevices } = await loadDevicesModule();
171
+ const devices = await firstValueFrom(getVideoDevices());
172
+
173
+ expect(
174
+ vi.mocked(mediaDevices.enumerateDevices).mock.calls.length,
175
+ ).toBeGreaterThanOrEqual(2);
176
+ expect(permissionInstances[0].prompt).toHaveBeenCalled();
177
+ expect(devices).toEqual([
178
+ { kind: 'videoinput', label: 'Cam 1', deviceId: 'id-1', groupId: 'g1' },
179
+ { kind: 'videoinput', label: 'Cam 2', deviceId: 'id-2', groupId: 'g2' },
180
+ ]);
181
+ });
182
+
183
+ it('getAudioOutputDevices prompts and filters devices', async () => {
184
+ const mediaDevices = setupMediaDevices({
185
+ enumerateDevices: vi
186
+ .fn()
187
+ .mockResolvedValueOnce([
188
+ { kind: 'audiooutput', label: '', deviceId: 'id-1', groupId: 'g1' },
189
+ {
190
+ kind: 'audiooutput',
191
+ label: 'Default speaker',
192
+ deviceId: 'default',
193
+ groupId: 'g1',
194
+ },
195
+ ])
196
+ .mockResolvedValueOnce([
197
+ {
198
+ kind: 'audiooutput',
199
+ label: 'Speaker 1',
200
+ deviceId: 'id-1',
201
+ groupId: 'g1',
202
+ },
203
+ {
204
+ kind: 'audiooutput',
205
+ label: 'Speaker 2',
206
+ deviceId: 'id-2',
207
+ groupId: 'g2',
208
+ },
209
+ ]),
210
+ });
211
+
212
+ const { getAudioOutputDevices } = await loadDevicesModule();
213
+ const devices = await firstValueFrom(getAudioOutputDevices());
214
+
215
+ expect(
216
+ vi.mocked(mediaDevices.enumerateDevices).mock.calls.length,
217
+ ).toBeGreaterThanOrEqual(2);
218
+ expect(permissionInstances[0].prompt).toHaveBeenCalled();
219
+ expect(devices).toEqual([
220
+ {
221
+ kind: 'audiooutput',
222
+ label: 'Speaker 1',
223
+ deviceId: 'id-1',
224
+ groupId: 'g1',
225
+ },
226
+ {
227
+ kind: 'audiooutput',
228
+ label: 'Speaker 2',
229
+ deviceId: 'id-2',
230
+ groupId: 'g2',
231
+ },
232
+ ]);
233
+ });
234
+
235
+ it('getAudioStream retries with relaxed constraints when deviceId fails', async () => {
236
+ const stream = {
237
+ getVideoTracks: () => [],
238
+ getAudioTracks: () => [],
239
+ } as MediaStream;
240
+ const mediaDevices = setupMediaDevices({
241
+ getUserMedia: vi
242
+ .fn()
243
+ .mockRejectedValueOnce(
244
+ Object.assign(new Error('fail'), { name: 'OverconstrainedError' }),
245
+ )
246
+ .mockResolvedValueOnce(stream),
247
+ });
248
+
249
+ const { getAudioStream } = await loadDevicesModule();
250
+ await getAudioStream({ deviceId: { exact: 'mic-1' } });
251
+
252
+ expect(mediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
253
+ const calls = vi.mocked(mediaDevices.getUserMedia).mock.calls;
254
+ const firstCall = calls[0][0];
255
+ const secondCall = calls[1][0];
256
+ expect(firstCall.audio.deviceId).toEqual({ exact: 'mic-1' });
257
+ expect(secondCall.audio.deviceId).toBeUndefined();
258
+ });
259
+
260
+ it('getVideoStream triggers devicechange on Firefox', async () => {
261
+ const stream = {
262
+ getVideoTracks: () => [
263
+ { getSettings: () => ({ width: 1280, height: 720 }) },
264
+ ],
265
+ getAudioTracks: () => [],
266
+ } as MediaStream;
267
+ const mediaDevices = setupMediaDevices({
268
+ getUserMedia: vi.fn().mockResolvedValue(stream),
269
+ });
270
+
271
+ const { getVideoStream } = await loadDevicesModule();
272
+ const dispatchEventSpy = vi.spyOn(mediaDevices, 'dispatchEvent');
273
+ const browsers = await import('../../helpers/browsers');
274
+ vi.mocked(browsers.isFirefox).mockReturnValue(true);
275
+ await getVideoStream();
276
+
277
+ expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event));
278
+ });
279
+
280
+ it('getVideoStream retries with relaxed constraints when deviceId fails', async () => {
281
+ const stream = {
282
+ getVideoTracks: () => [
283
+ { getSettings: () => ({ width: 1280, height: 720 }) },
284
+ ],
285
+ getAudioTracks: () => [],
286
+ } as MediaStream;
287
+ const mediaDevices = setupMediaDevices({
288
+ getUserMedia: vi
289
+ .fn()
290
+ .mockRejectedValueOnce(
291
+ Object.assign(new Error('fail'), { name: 'NotFoundError' }),
292
+ )
293
+ .mockResolvedValueOnce(stream),
294
+ });
295
+
296
+ const { getVideoStream } = await loadDevicesModule();
297
+ await getVideoStream({ deviceId: { exact: 'cam-1' } });
298
+
299
+ const calls = vi.mocked(mediaDevices.getUserMedia).mock.calls;
300
+ expect(calls.length).toBe(2);
301
+ expect(calls[0][0].video.deviceId).toEqual({ exact: 'cam-1' });
302
+ expect(calls[1][0].video.deviceId).toBeUndefined();
303
+ });
304
+
305
+ it('getScreenShareStream propagates getDisplayMedia errors', async () => {
306
+ const error = new Error('denied');
307
+ const mediaDevices = setupMediaDevices({
308
+ getDisplayMedia: vi.fn().mockRejectedValueOnce(error),
309
+ });
310
+
311
+ const { getScreenShareStream } = await loadDevicesModule();
312
+ await expect(getScreenShareStream()).rejects.toBe(error);
313
+ expect(mediaDevices.getDisplayMedia).toHaveBeenCalledOnce();
314
+ });
315
+
316
+ it('deviceIds$ emits enumerated devices and reacts to devicechange', async () => {
317
+ vi.useFakeTimers();
318
+ const mediaDevices = setupMediaDevices({
319
+ enumerateDevices: vi
320
+ .fn()
321
+ .mockResolvedValueOnce([
322
+ {
323
+ kind: 'audioinput',
324
+ label: 'Mic 1',
325
+ deviceId: 'mic-1',
326
+ groupId: 'g1',
327
+ },
328
+ ])
329
+ .mockResolvedValueOnce([
330
+ {
331
+ kind: 'audioinput',
332
+ label: 'Mic 2',
333
+ deviceId: 'mic-2',
334
+ groupId: 'g2',
335
+ },
336
+ ]),
337
+ });
338
+
339
+ const { deviceIds$ } = await loadDevicesModule();
340
+ const first = await firstValueFrom(deviceIds$!);
341
+ expect(first).toEqual([
342
+ {
343
+ kind: 'audioinput',
344
+ label: 'Mic 1',
345
+ deviceId: 'mic-1',
346
+ groupId: 'g1',
347
+ },
348
+ ]);
349
+
350
+ const nextValue = firstValueFrom(deviceIds$!.pipe(skip(1)));
351
+ mediaDevices.dispatchEvent(new Event('devicechange'));
352
+ await vi.advanceTimersByTimeAsync(500);
353
+ const second = await nextValue;
354
+ expect(second).toEqual([
355
+ {
356
+ kind: 'audioinput',
357
+ label: 'Mic 2',
358
+ deviceId: 'mic-2',
359
+ groupId: 'g2',
360
+ },
361
+ ]);
362
+ vi.useRealTimers();
363
+ });
364
+
365
+ it('resolveDeviceId resolves default device by group id', async () => {
366
+ const devices = [
367
+ {
368
+ kind: 'audioinput',
369
+ label: 'Default',
370
+ deviceId: 'default',
371
+ groupId: 'group-1',
372
+ },
373
+ {
374
+ kind: 'audioinput',
375
+ label: 'Mic 1',
376
+ deviceId: 'mic-1',
377
+ groupId: 'group-1',
378
+ },
379
+ ] as MediaDeviceInfo[];
380
+
381
+ setupMediaDevices({
382
+ enumerateDevices: vi.fn(async () => devices),
383
+ });
384
+
385
+ const { deviceIds$, resolveDeviceId } = await loadDevicesModule();
386
+ await firstValueFrom(deviceIds$!);
387
+
388
+ expect(resolveDeviceId('default', 'audioinput')).toBe('mic-1');
389
+ });
390
+ });
@@ -0,0 +1,58 @@
1
+ const createTrack = (
2
+ settings: MediaTrackSettings,
3
+ extras: Partial<MediaStreamTrack> = {},
4
+ ) => {
5
+ const eventHandlers = {} as Record<
6
+ string,
7
+ EventListenerOrEventListenerObject
8
+ >;
9
+ const track: Partial<MediaStreamTrack> = {
10
+ getSettings: () => settings,
11
+ enabled: true,
12
+ readyState: 'live',
13
+ stop: () => {
14
+ // @ts-expect-error read-only property
15
+ track.readyState = 'ended';
16
+ },
17
+ addEventListener: (
18
+ event: string,
19
+ handler: EventListenerOrEventListenerObject,
20
+ ) => {
21
+ eventHandlers[event] = handler;
22
+ },
23
+ removeEventListener(type: string) {
24
+ delete eventHandlers[type];
25
+ },
26
+ ...extras,
27
+ };
28
+
29
+ return track as MediaStreamTrack;
30
+ };
31
+
32
+ export const createAudioStreamForDevice = (
33
+ deviceId: string,
34
+ label: string,
35
+ ): MediaStream => {
36
+ const track = createTrack({ deviceId }, { label });
37
+ return {
38
+ getTracks: () => [track],
39
+ getAudioTracks: () => [track],
40
+ } as unknown as MediaStream;
41
+ };
42
+
43
+ export const createVideoStreamForDevice = (
44
+ deviceId: string,
45
+ facingMode: 'user' | 'environment' = 'user',
46
+ ): MediaStream => {
47
+ const track = createTrack({
48
+ deviceId,
49
+ width: 1280,
50
+ height: 720,
51
+ facingMode,
52
+ });
53
+
54
+ return {
55
+ getTracks: () => [track],
56
+ getVideoTracks: () => [track],
57
+ } as unknown as MediaStream;
58
+ };
@@ -127,6 +127,9 @@ export const mockAudioStream = () => {
127
127
  ) => {
128
128
  track.eventHandlers[event] = handler;
129
129
  },
130
+ removeEventListener(type: string) {
131
+ delete track.eventHandlers[type];
132
+ },
130
133
  };
131
134
  return {
132
135
  getTracks: () => [track],
@@ -156,6 +159,9 @@ export const mockVideoStream = (
156
159
  ) => {
157
160
  track.eventHandlers[event] = handler;
158
161
  },
162
+ removeEventListener(type: string) {
163
+ delete track.eventHandlers[type];
164
+ },
159
165
  };
160
166
  return {
161
167
  getTracks: () => [track],
@@ -180,6 +186,9 @@ export const mockScreenShareStream = (includeAudio: boolean = true) => {
180
186
  ) => {
181
187
  track.eventHandlers[event] = handler;
182
188
  },
189
+ removeEventListener(type: string) {
190
+ delete track.eventHandlers[type];
191
+ },
183
192
  };
184
193
 
185
194
  const tracks = [track];
@@ -200,6 +209,9 @@ export const mockScreenShareStream = (includeAudio: boolean = true) => {
200
209
  ) => {
201
210
  audioTrack.eventHandlers[event] = handler;
202
211
  },
212
+ removeEventListener(type: string) {
213
+ delete track.eventHandlers[type];
214
+ },
203
215
  };
204
216
  tracks.push(audioTrack);
205
217
  }
@@ -211,6 +223,29 @@ export const mockScreenShareStream = (includeAudio: boolean = true) => {
211
223
  } as any as MediaStream;
212
224
  };
213
225
 
226
+ export type LocalStorageMock = {
227
+ getItem: (key: string) => string | null;
228
+ setItem: (key: string, value: string) => void;
229
+ removeItem: (key: string) => void;
230
+ clear: () => void;
231
+ };
232
+
233
+ export const createLocalStorageMock = (): LocalStorageMock => {
234
+ const store = new Map<string, string>();
235
+ return {
236
+ getItem: (key) => (store.has(key) ? store.get(key)! : null),
237
+ setItem: (key, value) => {
238
+ store.set(key, String(value));
239
+ },
240
+ removeItem: (key) => {
241
+ store.delete(key);
242
+ },
243
+ clear: () => {
244
+ store.clear();
245
+ },
246
+ };
247
+ };
248
+
214
249
  let deviceIds: Subject<MediaDeviceInfo[]>;
215
250
  export const mockDeviceIds$ = () => {
216
251
  deviceIds = new Subject();