Skip to content

Commit f09c510

Browse files
committed
refactor: update logic for filterToSupportedConstraints
1 parent 20ec267 commit f09c510

4 files changed

Lines changed: 229 additions & 50 deletions

File tree

src/media/local-audio-stream.spec.ts

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as media from '.';
2+
import { getSupportedConstraints } from '../mocks/media-track-supported-constraints';
23
import { createMockedAudioStream, createMockedStream } from '../util/test-utils';
34
import { LocalAudioStream } from './local-audio-stream';
45
import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream';
@@ -30,7 +31,20 @@ describe('LocalAudioStream', () => {
3031
let getUserMediaSpy: jest.SpyInstance;
3132
let newAudioTrack: MediaStreamTrack;
3233

34+
// Stub navigator.mediaDevices.getSupportedConstraints (absent in jsdom)
35+
// so the filter in reacquireInputTrack mirrors a spec-compliant browser.
36+
let originalMediaDevices: MediaDevices | undefined;
37+
3338
beforeEach(async () => {
39+
originalMediaDevices = navigator.mediaDevices;
40+
Object.defineProperty(navigator, 'mediaDevices', {
41+
configurable: true,
42+
value: {
43+
...(originalMediaDevices ?? {}),
44+
getSupportedConstraints,
45+
},
46+
});
47+
3448
audioStream = createMockedAudioStream();
3549
audioLocalStream = new LocalAudioStream(audioStream);
3650

@@ -66,6 +80,10 @@ describe('LocalAudioStream', () => {
6680

6781
afterEach(() => {
6882
getUserMediaSpy.mockRestore();
83+
Object.defineProperty(navigator, 'mediaDevices', {
84+
configurable: true,
85+
value: originalMediaDevices,
86+
});
6987
});
7088

7189
it('should call getUserMedia with current settings and effect constraints', async () => {
@@ -86,6 +104,31 @@ describe('LocalAudioStream', () => {
86104
});
87105
});
88106

107+
it('should drop unsupported settings names before passing them to getUserMedia', async () => {
108+
expect.hasAssertions();
109+
110+
const inputTrack = audioStream.getTracks()[0];
111+
(inputTrack.getSettings as jest.Mock).mockReturnValue({
112+
...audioSettings,
113+
// Vendor / non-standard properties that may appear in MediaTrackSettings
114+
// but are not in MediaTrackSupportedConstraints. These must not reach
115+
// getUserMedia, since they would be silently dropped by WebIDL anyway
116+
// and only add noise to the constraints dictionary.
117+
restrictOwnAudio: true,
118+
suppressLocalAudioPlayback: false,
119+
} as MediaTrackSettings);
120+
121+
await constraintsRequiredHandler({ autoGainControl: false });
122+
123+
const passedConstraints = getUserMediaSpy.mock.calls[0][0].audio;
124+
expect(passedConstraints).not.toHaveProperty('restrictOwnAudio');
125+
expect(passedConstraints).not.toHaveProperty('suppressLocalAudioPlayback');
126+
expect(passedConstraints).toMatchObject({
127+
deviceId: { exact: 'test-device-id' },
128+
autoGainControl: false,
129+
});
130+
});
131+
89132
it('should skip re-acquisition when nothing is saved and constraints are released', async () => {
90133
expect.hasAssertions();
91134

@@ -145,6 +188,48 @@ describe('LocalAudioStream', () => {
145188
expect(getUserMediaSpy).not.toHaveBeenCalled();
146189
});
147190

191+
it('should preserve the saved baseline when a later constraints-required falls back', async () => {
192+
expect.hasAssertions();
193+
194+
// First required: succeeds and saves { autoGainControl: true } as the user baseline.
195+
await constraintsRequiredHandler({ autoGainControl: false });
196+
197+
// Track now reflects the effect-modified AGC=false state.
198+
(audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]);
199+
jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({
200+
...audioSettings,
201+
autoGainControl: false,
202+
});
203+
204+
// Second required: first getUserMedia fails, fallback succeeds.
205+
const fallbackStream = createMockedAudioStream();
206+
const [fallbackTrack] = fallbackStream.getAudioTracks();
207+
jest.spyOn(fallbackTrack, 'getSettings').mockReturnValue({
208+
...audioSettings,
209+
autoGainControl: false,
210+
});
211+
getUserMediaSpy
212+
.mockRejectedValueOnce(new Error('OverconstrainedError'))
213+
.mockResolvedValueOnce(fallbackStream);
214+
215+
await constraintsRequiredHandler({ noiseSuppression: false });
216+
217+
(audioStream.getTracks as jest.Mock).mockReturnValue([fallbackTrack]);
218+
getUserMediaSpy.mockClear();
219+
220+
// Released: must restore both user-baseline AGC=true and NS=true,
221+
// not skip restoration because of a cleared baseline.
222+
await constraintsReleasedHandler();
223+
224+
expect(getUserMediaSpy).toHaveBeenCalledTimes(1);
225+
expect(getUserMediaSpy).toHaveBeenLastCalledWith({
226+
audio: expect.objectContaining({
227+
autoGainControl: true,
228+
noiseSuppression: true,
229+
}),
230+
});
231+
});
232+
148233
it('should replace the input track on the first effect', async () => {
149234
expect.hasAssertions();
150235

@@ -217,19 +302,62 @@ describe('LocalAudioStream', () => {
217302
});
218303
});
219304

220-
it('should emit Ended when both getUserMedia calls fail', async () => {
305+
it('should emit Ended when both getUserMedia calls fail and the input track is ended', async () => {
221306
expect.hasAssertions();
222307

223308
const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit');
224309

310+
const inputTrack = audioStream.getTracks()[0];
311+
getUserMediaSpy.mockImplementationOnce(async () => {
312+
// Mimic the device disappearing: the original track ends before the
313+
// fallback getUserMedia resolves, so the catch path sees a non-live
314+
// input track and must emit Ended instead of silently bypassing.
315+
(inputTrack as { readyState: string }).readyState = 'ended';
316+
throw new Error('OverconstrainedError');
317+
});
318+
getUserMediaSpy.mockRejectedValueOnce(new Error('NotFoundError'));
319+
320+
await constraintsRequiredHandler({ autoGainControl: false });
321+
322+
expect(getUserMediaSpy).toHaveBeenCalledTimes(2);
323+
expect(endedSpy).toHaveBeenCalledWith();
324+
});
325+
326+
it('should fall back to raw mic and dispose the effect when both getUserMedia calls fail but the track is still live', async () => {
327+
expect.hasAssertions();
328+
329+
const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit');
330+
const constraintsChangeSpy = jest.spyOn(
331+
audioLocalStream[LocalStreamEventNames.ConstraintsChange],
332+
'emit'
333+
);
334+
const changeOutputTrackSpy = jest.spyOn(
335+
audioLocalStream as unknown as { changeOutputTrack: (t: MediaStreamTrack) => void },
336+
'changeOutputTrack'
337+
);
338+
339+
const inputTrack = audioStream.getTracks()[0];
340+
(inputTrack as { readyState: string }).readyState = 'live';
341+
225342
getUserMediaSpy
226343
.mockRejectedValueOnce(new Error('OverconstrainedError'))
227344
.mockRejectedValueOnce(new Error('NotFoundError'));
228345

229346
await constraintsRequiredHandler({ autoGainControl: false });
230347

231348
expect(getUserMediaSpy).toHaveBeenCalledTimes(2);
232-
expect(endedSpy).toHaveBeenCalledWith();
349+
// Output is rewired to the original (effect-bypassed) mic track.
350+
expect(changeOutputTrackSpy).toHaveBeenCalledWith(inputTrack);
351+
// The failing effect is disposed and removed from the chain so it
352+
// stops consuming CPU while running in bypass mode.
353+
expect(effect.dispose).toHaveBeenCalledWith();
354+
expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain(
355+
effect
356+
);
357+
// No new track was wired, so no ConstraintsChange; the stream is not
358+
// ended, so no Ended.
359+
expect(constraintsChangeSpy).not.toHaveBeenCalled();
360+
expect(endedSpy).not.toHaveBeenCalled();
233361
});
234362

235363
it('should skip re-acquisition when the track is already ended', async () => {

0 commit comments

Comments
 (0)