fix: Fix multiple bugs with new authentication flow

This commit is contained in:
2026-06-07 15:04:21 +02:00
parent 9fc26b1ccf
commit 83456c018c
137 changed files with 4710 additions and 281 deletions
@@ -0,0 +1,271 @@
import {
Injector,
runInInjectionContext,
signal,
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
ɵEffectScheduler as EffectScheduler
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { ScreenShareFacade } from '../../../screen-share';
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
import { VoicePlaybackService } from './voice-playback.service';
let audioContextCount = 0;
describe('VoicePlaybackService', () => {
beforeEach(() => {
audioContextCount = 0;
installAudioDomMocks();
installLocalStorageMock();
});
it('creates one audio pipeline per peer on first connect', () => {
const context = createServiceContext({ isVoiceConnected: true });
const stream = createMockAudioStream(['track-a']);
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
expect(audioContextCount).toBe(1);
});
it('reuses the existing pipeline when the same voice stream is handled again', () => {
const context = createServiceContext({ isVoiceConnected: true });
const stream = createMockAudioStream(['track-a']);
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
expect(audioContextCount).toBe(1);
});
it('reuses the pipeline when only the MediaStream wrapper changes but live audio tracks are unchanged', () => {
const context = createServiceContext({ isVoiceConnected: true });
const track = createMockAudioTrack('track-a');
const firstStream = createMockAudioStreamFromTracks([track]);
const secondStream = createMockAudioStreamFromTracks([track]);
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
expect(audioContextCount).toBe(1);
});
it('rebuilds the pipeline when the live audio track set changes', () => {
const context = createServiceContext({ isVoiceConnected: true });
const firstStream = createMockAudioStream(['track-a']);
const secondStream = createMockAudioStream(['track-b']);
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
expect(audioContextCount).toBe(2);
});
it('does not recreate pipelines for unrelated remote stream notifications', () => {
const context = createServiceContext({ isVoiceConnected: true });
const voiceStream = createMockAudioStream(['track-a']);
context.voiceConnection.getRemoteVoiceStream.mockReturnValue(voiceStream);
context.service.handleRemoteStream('peer-1', voiceStream, connectedOptions());
context.remoteStream$.next({ peerId: 'peer-1', stream: createMockAudioStream(['screen-audio']) });
expect(audioContextCount).toBe(1);
});
it('creates pipelines for each peer when multiple friends join voice', () => {
const context = createServiceContext({ isVoiceConnected: true });
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-1']), connectedOptions());
context.service.handleRemoteStream('peer-2', createMockAudioStream(['track-2']), connectedOptions());
expect(audioContextCount).toBe(2);
});
it('still applies updated playback options when reusing an existing pipeline', () => {
const context = createServiceContext({ isVoiceConnected: true });
const stream = createMockAudioStream(['track-a']);
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 1 }));
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 0.5, isDeafened: true }));
expect(audioContextCount).toBe(1);
expect(context.service.getUserVolume('peer-1')).toBe(100);
});
});
interface ServiceContext {
service: VoicePlaybackService;
voiceConnection: {
isVoiceConnected: ReturnType<typeof signal<boolean>>;
onRemoteStream: Subject<{ peerId: string; stream: MediaStream }>;
onVoiceConnected: Subject<void>;
onPeerDisconnected: Subject<string>;
getRemoteVoiceStream: ReturnType<typeof vi.fn>;
getConnectedPeers: ReturnType<typeof vi.fn>;
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
};
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
}
function createServiceContext(options: { isVoiceConnected?: boolean } = {}): ServiceContext {
const remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
const voiceConnection = {
isVoiceConnected: signal(options.isVoiceConnected ?? false),
onRemoteStream: remoteStream$,
onVoiceConnected: new Subject<void>(),
onPeerDisconnected: new Subject<string>(),
getRemoteVoiceStream: vi.fn(() => null),
getConnectedPeers: vi.fn(() => []),
syncOutgoingVoiceRouting: vi.fn()
};
const screenShare = {
isScreenShareRemotePlaybackSuppressed: signal(false),
forceDefaultRemotePlaybackOutput: signal(false)
};
const store = {
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return signal(null);
}
if (selector === selectAllUsers) {
return signal([]);
}
throw new Error(`Unexpected selector in VoicePlaybackService test: ${String(selector)}`);
})
};
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
const effectScheduler = {
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
}),
flush: vi.fn(() => {
for (const scheduledEffect of scheduledEffects) {
if (scheduledEffect.dirty) {
scheduledEffect.run();
}
}
}),
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.delete(scheduledEffect);
}),
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
})
};
const injector = Injector.create({
providers: [
VoicePlaybackService,
{
provide: ChangeDetectionScheduler,
useValue: { notify: vi.fn() }
},
{ provide: EffectScheduler, useValue: effectScheduler },
{ provide: VoiceConnectionFacade, useValue: voiceConnection },
{ provide: ScreenShareFacade, useValue: screenShare },
{ provide: Store, useValue: store }
]
});
return {
service: runInInjectionContext(injector, () => injector.get(VoicePlaybackService)),
voiceConnection,
remoteStream$
};
}
function connectedOptions(overrides: Partial<{ outputVolume: number; isDeafened: boolean }> = {}) {
return {
isConnected: true,
outputVolume: overrides.outputVolume ?? 1,
isDeafened: overrides.isDeafened ?? false
};
}
function createMockAudioTrack(id: string, readyState: MediaStreamTrackState = 'live'): MediaStreamTrack {
return {
id,
kind: 'audio',
readyState,
enabled: true
} as MediaStreamTrack;
}
function createMockAudioStream(trackIds: string[]): MediaStream {
return createMockAudioStreamFromTracks(trackIds.map((id) => createMockAudioTrack(id)));
}
function createMockAudioStreamFromTracks(tracks: MediaStreamTrack[]): MediaStream {
return {
getAudioTracks: () => tracks,
getTracks: () => tracks
} as unknown as MediaStream;
}
function installAudioDomMocks(): void {
vi.stubGlobal('MediaStream', class MediaStream {
constructor(private readonly tracks: MediaStreamTrack[] = []) {}
getAudioTracks(): MediaStreamTrack[] {
return this.tracks;
}
getTracks(): MediaStreamTrack[] {
return this.tracks;
}
});
vi.stubGlobal('Audio', class {
muted = false;
volume = 1;
srcObject: MediaStream | null = null;
play = vi.fn().mockResolvedValue(undefined);
remove = vi.fn();
});
vi.stubGlobal('AudioContext', class {
state = 'running';
close = vi.fn().mockResolvedValue(undefined);
constructor() {
audioContextCount += 1;
}
createGain() {
return {
gain: { value: 0 },
connect: vi.fn()
};
}
createMediaStreamSource() {
return { connect: vi.fn() };
}
createMediaStreamDestination() {
return { stream: createMockAudioStream(['destination']) };
}
});
}
function installLocalStorageMock(): void {
const storage = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
}
});
}
@@ -112,10 +112,17 @@ export class VoicePlaybackService {
return;
}
this.removePipeline(peerId);
this.rawRemoteStreams.set(peerId, stream);
this.masterVolume = options.outputVolume;
this.deafened = options.isDeafened;
if (this.shouldReusePipeline(peerId, stream)) {
this.rawRemoteStreams.set(peerId, stream);
this.applyGain(peerId);
return;
}
this.removePipeline(peerId);
this.rawRemoteStreams.set(peerId, stream);
this.createPipeline(peerId, stream);
}
@@ -142,13 +149,19 @@ export class VoicePlaybackService {
for (const peerId of peers) {
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
if (stream && this.hasAudio(stream)) {
const trackedRaw = this.rawRemoteStreams.get(peerId);
if (!trackedRaw || trackedRaw !== stream) {
this.handleRemoteStream(peerId, stream, options);
}
if (!stream || !this.hasAudio(stream)) {
continue;
}
if (this.shouldReusePipeline(peerId, stream)) {
this.masterVolume = options.outputVolume;
this.deafened = options.isDeafened;
this.rawRemoteStreams.set(peerId, stream);
this.applyGain(peerId);
continue;
}
this.handleRemoteStream(peerId, stream, options);
}
}
@@ -433,4 +446,41 @@ export class VoicePlaybackService {
private hasAudio(stream: MediaStream): boolean {
return stream.getAudioTracks().length > 0;
}
/**
* Remote composite-stream notifications (camera, screen share, renegotiation)
* can arrive without any change to the underlying voice audio tracks.
* Rebuilding the Web Audio graph in that case only churns AudioContexts.
*/
private shouldReusePipeline(peerId: string, stream: MediaStream): boolean {
const trackedRaw = this.rawRemoteStreams.get(peerId);
return this.peerPipelines.has(peerId)
&& !!trackedRaw
&& this.streamsShareLiveAudioTracks(trackedRaw, stream);
}
private streamsShareLiveAudioTracks(previous: MediaStream, next: MediaStream): boolean {
const previousTrackIds = this.getLiveAudioTrackIds(previous);
const nextTrackIds = this.getLiveAudioTrackIds(next);
if (previousTrackIds.length === 0 || nextTrackIds.length === 0) {
return false;
}
if (previousTrackIds.length !== nextTrackIds.length) {
return false;
}
const previousIds = new Set(previousTrackIds);
return nextTrackIds.every((trackId) => previousIds.has(trackId));
}
private getLiveAudioTrackIds(stream: MediaStream): string[] {
return stream
.getAudioTracks()
.filter((track) => track.readyState === 'live')
.map((track) => track.id);
}
}