@@ -6,13 +6,15 @@ vi.mock('../../shared/config', () => ({ API_BASE_URL: 'http://localhost:3000' })
66vi . mock ( '@/lib/ipc' , ( ) => ( {
77 getElectronAPI : ( ) => ( { invoke : vi . fn ( ) . mockResolvedValue ( { token : 'local-test-token' } ) } ) ,
88} ) ) ;
9- vi . mock ( '@/lib/remoteAudioGain' , ( ) => ( {
10- amplifyRemoteAudio : ( track : MediaStreamTrack ) => ( {
9+ const playback = vi . hoisted ( ( ) => ( { amplify : vi . fn ( ) } ) ) ;
10+ vi . mock ( '@/lib/remoteAudioGain' , ( ) => ( { amplifyRemoteAudio : playback . amplify } ) ) ;
11+ function amplify ( track : MediaStreamTrack ) {
12+ return {
1113 stream : new MediaStream ( [ track ] ) ,
1214 dispose : vi . fn ( ) ,
1315 setGain : vi . fn ( ) ,
14- } ) ,
15- } ) ) ;
16+ } ;
17+ }
1618
1719class TestEventSource {
1820 static latest : TestEventSource ;
@@ -157,7 +159,7 @@ async function setup() {
157159 mediaDevices : { getUserMedia : vi . fn ( ) . mockResolvedValue ( new MediaStream ( [ hostMic ] ) ) } ,
158160 } ) ;
159161 const localStream = new MediaStream ( [ hostMic ] ) ;
160- const { result, unmount } = renderHook ( ( ) =>
162+ const { result, unmount, rerender } = renderHook ( ( ) =>
161163 useWebRTCHostAPI ( { sessionId : 'group' , hostId : 'host' , localStream } )
162164 ) ;
163165 await act ( async ( ) => {
@@ -166,10 +168,11 @@ async function setup() {
166168 await emit ( 'connected' , { } ) ;
167169 const peer = ( id : string ) =>
168170 result . current . viewers . get ( id ) ! . peerConnection as unknown as TestPeer ;
169- return { result, peer, unmount, hostMic } ;
171+ return { result, peer, unmount, hostMic, rerender } ;
170172}
171173
172174beforeEach ( ( ) => {
175+ playback . amplify . mockReset ( ) . mockImplementation ( amplify ) ;
173176 signals . length = 0 ;
174177 mockFetch . mockClear ( ) ;
175178 vi . stubGlobal ( 'EventSource' , TestEventSource ) ;
@@ -201,6 +204,234 @@ afterEach(() => {
201204} ) ;
202205
203206describe ( 'desktop P2P group negotiation' , ( ) => {
207+ it ( 'owns one relay per source and removes it on leave before a same-ID rejoin' , async ( ) => {
208+ const { peer } = await setup ( ) ;
209+ await join ( 'source' ) ;
210+ await join ( 'target' ) ;
211+ await answer ( 'source' ) ;
212+ await answer ( 'target' ) ;
213+ const first = track ( 'first' ) ;
214+ await act ( async ( ) => {
215+ peer ( 'source' ) . ontrack ?.( { track : first } ) ;
216+ await flush ( ) ;
217+ } ) ;
218+ await answer ( 'target' ) ;
219+ await act ( async ( ) => {
220+ peer ( 'source' ) . ontrack ?.( { track : first } ) ;
221+ await flush ( ) ;
222+ } ) ;
223+ expect (
224+ peer ( 'target' )
225+ . getSenders ( )
226+ . filter ( ( s ) => s . track === first )
227+ ) . toHaveLength ( 1 ) ;
228+ expect ( playback . amplify ) . toHaveBeenCalledTimes ( 1 ) ;
229+ // A new receiver object may retain the previous SDP msid.
230+ const replacement = track ( 'first' ) ;
231+ await act ( async ( ) => {
232+ peer ( 'source' ) . ontrack ?.( { track : replacement } ) ;
233+ await flush ( ) ;
234+ } ) ;
235+ expect (
236+ peer ( 'target' )
237+ . getSenders ( )
238+ . filter ( ( s ) => s . track === first )
239+ ) . toHaveLength ( 0 ) ;
240+ expect ( playback . amplify ) . toHaveBeenCalledTimes ( 2 ) ;
241+ await emit ( 'presence-leave' , { presences : [ { user_id : 'source' } ] } ) ;
242+ expect (
243+ peer ( 'target' )
244+ . getSenders ( )
245+ . filter ( ( s ) => s . track === replacement )
246+ ) . toHaveLength ( 0 ) ;
247+ await answer ( 'target' ) ;
248+ await join ( 'source' ) ;
249+ const rejoined = track ( 'rejoined' ) ;
250+ await act ( async ( ) => {
251+ peer ( 'source' ) . ontrack ?.( { track : rejoined } ) ;
252+ await flush ( ) ;
253+ } ) ;
254+ expect (
255+ peer ( 'target' )
256+ . getSenders ( )
257+ . filter ( ( s ) => s . track ?. kind === 'audio' )
258+ ) . toHaveLength ( 2 ) ;
259+ } ) ;
260+
261+ it ( 'keeps muted relays for late joiners and preserves mute until channel open and rejoin' , async ( ) => {
262+ const { result, peer } = await setup ( ) ;
263+ await join ( 'source' ) ;
264+ act ( ( ) => result . current . muteViewer ( 'source' , true ) ) ;
265+ const mic = track ( 'muted' ) ;
266+ await act ( async ( ) => {
267+ peer ( 'source' ) . ontrack ?.( { track : mic } ) ;
268+ await flush ( ) ;
269+ } ) ;
270+ expect ( mic . enabled ) . toBe ( false ) ;
271+ await join ( 'target' ) ;
272+ expect (
273+ peer ( 'target' )
274+ . getSenders ( )
275+ . some ( ( s ) => s . track === mic )
276+ ) . toBe ( true ) ;
277+ act ( ( ) => {
278+ peer ( 'source' ) . dataChannel . readyState = 'open' ;
279+ peer ( 'source' ) . dataChannel . onopen ?.( ) ;
280+ } ) ;
281+ expect ( peer ( 'source' ) . dataChannel . send ) . toHaveBeenCalledWith (
282+ expect . stringContaining ( '"muted":true' )
283+ ) ;
284+ act ( ( ) => result . current . muteViewer ( 'source' , false ) ) ;
285+ expect ( mic . enabled ) . toBe ( true ) ;
286+ act ( ( ) => result . current . muteViewer ( 'source' , true ) ) ;
287+ await emit ( 'presence-leave' , { presences : [ { user_id : 'source' } ] } ) ;
288+ await join ( 'source' ) ;
289+ const next = track ( 'next-muted' ) ;
290+ act ( ( ) => peer ( 'source' ) . ontrack ?.( { track : next } ) ) ;
291+ expect ( next . enabled ) . toBe ( false ) ;
292+ } ) ;
293+
294+ it ( 'disposes replaced host playback and relays even when amplification fails' , async ( ) => {
295+ const { peer } = await setup ( ) ;
296+ await join ( 'source' ) ;
297+ await join ( 'target' ) ;
298+ await answer ( 'target' ) ;
299+ await act ( async ( ) => {
300+ peer ( 'source' ) . ontrack ?.( { track : track ( 'old' ) } ) ;
301+ await flush ( ) ;
302+ } ) ;
303+ const old = playback . amplify . mock . results [ 0 ] . value as ReturnType < typeof amplify > ;
304+ const next = track ( 'next' ) ;
305+ playback . amplify . mockImplementationOnce ( ( ) => {
306+ throw new Error ( 'audio unavailable' ) ;
307+ } ) ;
308+ await act ( async ( ) => {
309+ peer ( 'source' ) . ontrack ?.( { track : next } ) ;
310+ await flush ( ) ;
311+ } ) ;
312+ expect ( old . dispose ) . toHaveBeenCalledOnce ( ) ;
313+ expect (
314+ peer ( 'target' )
315+ . getSenders ( )
316+ . some ( ( s ) => s . track === next )
317+ ) . toBe ( true ) ;
318+ } ) ;
319+
320+ it ( 'does not restore video after unpublish supersedes an awaited audio replacement' , async ( ) => {
321+ const { result, peer } = await setup ( ) ;
322+ await join ( 'target' ) ;
323+ await answer ( 'target' ) ;
324+ const pending = deferred < undefined > ( ) ;
325+ peer ( 'target' ) . senders [ 0 ] . replaceTrack . mockImplementationOnce (
326+ async ( replacement : MediaStreamTrack ) => {
327+ await pending . promise ;
328+ peer ( 'target' ) . senders [ 0 ] . track = replacement ;
329+ }
330+ ) ;
331+ let publishing ! : Promise < void > ;
332+ let stopping ! : Promise < void > ;
333+ await act ( async ( ) => {
334+ publishing = result . current . publishStream (
335+ new MediaStream ( [ track ( 'screen' , 'video' ) , track ( 'other-audio' ) ] )
336+ ) ;
337+ await flush ( ) ;
338+ stopping = result . current . unpublishStream ( ) ;
339+ pending . resolve ( undefined ) ;
340+ await Promise . all ( [ publishing , stopping ] ) ;
341+ await flush ( ) ;
342+ } ) ;
343+ expect (
344+ peer ( 'target' )
345+ . getSenders ( )
346+ . some ( ( s ) => s . track ?. kind === 'video' )
347+ ) . toBe ( false ) ;
348+ expect (
349+ peer ( 'target' )
350+ . getSenders ( )
351+ . some ( ( s ) => s . track ?. id === 'host-mic' )
352+ ) . toBe ( true ) ;
353+ } ) ;
354+
355+ it ( 'skips a publication already superseded by its caller' , async ( ) => {
356+ const { result, peer } = await setup ( ) ;
357+ await join ( 'target' ) ;
358+ await answer ( 'target' ) ;
359+ await act ( async ( ) => {
360+ await result . current . publishStream ( new MediaStream ( [ track ( 'stale' , 'video' ) ] ) , ( ) => true ) ;
361+ } ) ;
362+ expect (
363+ peer ( 'target' )
364+ . getSenders ( )
365+ . some ( ( s ) => s . track ?. id === 'stale' )
366+ ) . toBe ( false ) ;
367+ } ) ;
368+
369+ it . each ( [ 'publish' , 'unpublish' ] as const ) (
370+ 'continues %s for later viewers when one leaves during replaceTrack' ,
371+ async ( operation ) => {
372+ const { result, peer } = await setup ( ) ;
373+ for ( const id of [ 'first' , 'middle' , 'last' ] ) {
374+ await join ( id ) ;
375+ await answer ( id ) ;
376+ }
377+ const oldVideo = track ( 'old-screen' , 'video' ) ;
378+ const screenAudio = track ( 'screen-audio' ) ;
379+ await act ( async ( ) => {
380+ await result . current . publishStream ( new MediaStream ( [ oldVideo , screenAudio ] ) ) ;
381+ } ) ;
382+ for ( const id of [ 'first' , 'middle' , 'last' ] ) await answer ( id ) ;
383+ const middle = peer ( 'middle' ) ;
384+ const sender = middle . senders . find (
385+ ( item ) => item . track ?. kind === ( operation === 'publish' ? 'video' : 'audio' )
386+ ) ! ;
387+ const pending = deferred < undefined > ( ) ;
388+ sender . replaceTrack . mockImplementationOnce ( async ( replacement : MediaStreamTrack ) => {
389+ await pending . promise ;
390+ sender . track = replacement ;
391+ } ) ;
392+ const newVideo = track ( 'new-screen' , 'video' ) ;
393+ let updating ! : Promise < void > ;
394+ await act ( async ( ) => {
395+ updating =
396+ operation === 'publish'
397+ ? result . current . publishStream ( new MediaStream ( [ newVideo , screenAudio ] ) )
398+ : result . current . unpublishStream ( ) ;
399+ await flush ( ) ;
400+ } ) ;
401+ expect ( sender . replaceTrack ) . toHaveBeenCalled ( ) ;
402+ await emit ( 'presence-leave' , { presences : [ { user_id : 'middle' } ] } ) ;
403+ await act ( async ( ) => {
404+ pending . resolve ( undefined ) ;
405+ await updating ;
406+ } ) ;
407+ const videos = peer ( 'last' ) . senders . filter ( ( item ) => item . track ?. kind === 'video' ) ;
408+ expect ( videos . map ( ( item ) => item . track ) ) . toEqual ( operation === 'publish' ? [ newVideo ] : [ ] ) ;
409+ }
410+ ) ;
411+
412+ it ( 'keeps the presentation stream across renders and excludes cancelled streams from late joins' , async ( ) => {
413+ const { result, peer, rerender } = await setup ( ) ;
414+ const video = track ( 'presentation' , 'video' ) ;
415+ let cancelled = false ;
416+ await act ( async ( ) => {
417+ await result . current . publishStream ( new MediaStream ( [ video ] ) , ( ) => cancelled ) ;
418+ } ) ;
419+ rerender ( ) ;
420+ await join ( 'late' ) ;
421+ expect ( peer ( 'late' ) . senders . some ( ( sender ) => sender . track === video ) ) . toBe ( true ) ;
422+ cancelled = true ;
423+ await join ( 'after-cancel' ) ;
424+ expect ( peer ( 'after-cancel' ) . senders . some ( ( sender ) => sender . track ?. kind === 'video' ) ) . toBe (
425+ false
426+ ) ;
427+ await act ( async ( ) => {
428+ await result . current . unpublishStream ( ) ;
429+ } ) ;
430+ rerender ( ) ;
431+ await join ( 'after-stop' ) ;
432+ expect ( peer ( 'after-stop' ) . senders . some ( ( sender ) => sender . track ?. kind === 'video' ) ) . toBe ( false ) ;
433+ } ) ;
434+
204435 it ( 'does not time out a participant while the final answer is being applied' , async ( ) => {
205436 vi . useFakeTimers ( ) ;
206437 const { result, peer } = await setup ( ) ;
0 commit comments