@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.
- package/CHANGELOG.md +15 -0
- package/dist/index.browser.es.js +206 -59
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +205 -58
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +206 -59
- package/dist/index.es.js.map +1 -1
- package/dist/src/StreamVideoClient.d.ts +2 -8
- package/dist/src/coordinator/connection/types.d.ts +5 -0
- package/dist/src/devices/CameraManager.d.ts +7 -2
- package/dist/src/devices/DeviceManager.d.ts +7 -15
- package/dist/src/devices/MicrophoneManager.d.ts +2 -1
- package/dist/src/devices/SpeakerManager.d.ts +6 -1
- package/dist/src/devices/devicePersistence.d.ts +27 -0
- package/dist/src/helpers/clientUtils.d.ts +1 -1
- package/dist/src/permissions/PermissionsContext.d.ts +1 -1
- package/dist/src/types.d.ts +38 -2
- package/package.json +1 -1
- package/src/Call.ts +5 -3
- package/src/StreamVideoClient.ts +1 -9
- package/src/coordinator/connection/types.ts +6 -0
- package/src/devices/CameraManager.ts +31 -11
- package/src/devices/DeviceManager.ts +113 -31
- package/src/devices/MicrophoneManager.ts +26 -8
- package/src/devices/ScreenShareManager.ts +7 -1
- package/src/devices/SpeakerManager.ts +62 -18
- package/src/devices/__tests__/CameraManager.test.ts +184 -21
- package/src/devices/__tests__/DeviceManager.test.ts +184 -2
- package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +146 -2
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -0
- package/src/devices/__tests__/ScreenShareManager.test.ts +2 -0
- package/src/devices/__tests__/SpeakerManager.test.ts +90 -0
- package/src/devices/__tests__/devicePersistence.test.ts +142 -0
- package/src/devices/__tests__/devices.test.ts +390 -0
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +58 -0
- package/src/devices/__tests__/mocks.ts +35 -0
- package/src/devices/devicePersistence.ts +106 -0
- package/src/devices/devices.ts +3 -3
- package/src/helpers/__tests__/DynascaleManager.test.ts +3 -1
- package/src/helpers/clientUtils.ts +1 -1
- package/src/permissions/PermissionsContext.ts +1 -0
- package/src/sorting/presets.ts +1 -1
- package/src/store/CallState.ts +1 -1
- 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();
|