11import * as media from '.' ;
2+ import { getSupportedConstraints } from '../mocks/media-track-supported-constraints' ;
23import { createMockedAudioStream , createMockedStream } from '../util/test-utils' ;
34import { LocalAudioStream } from './local-audio-stream' ;
45import { 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