feat(voice): журнал обработанных вебхуков LiveKit

This commit is contained in:
2026-09-20 18:45:14 +03:00
parent ecd7b3be10
commit 0db4c63023
19 changed files with 1578 additions and 59 deletions
+287
View File
@@ -0,0 +1,287 @@
/**
* Подставной `livekit-client` для тестов: комната, участники и события под
* управлением теста. В jsdom настоящий SDK не работает (нет WebRTC), поэтому
* `vi.mock('livekit-client', () => import('./fakeLiveKit'))` подменяет его
* целиком, а логика `lib/livekit.ts` и стора проверяется настоящая.
*/
export const RoomEvent = {
ParticipantConnected: 'participantConnected',
ParticipantDisconnected: 'participantDisconnected',
TrackSubscribed: 'trackSubscribed',
TrackUnsubscribed: 'trackUnsubscribed',
TrackPublished: 'trackPublished',
TrackUnpublished: 'trackUnpublished',
TrackMuted: 'trackMuted',
TrackUnmuted: 'trackUnmuted',
LocalTrackPublished: 'localTrackPublished',
LocalTrackUnpublished: 'localTrackUnpublished',
ConnectionQualityChanged: 'connectionQualityChanged',
ActiveSpeakersChanged: 'activeSpeakersChanged',
Reconnecting: 'reconnecting',
Reconnected: 'reconnected',
Disconnected: 'disconnected',
} as const;
export const ConnectionQuality = {
Excellent: 'excellent',
Good: 'good',
Poor: 'poor',
Lost: 'lost',
Unknown: 'unknown',
} as const;
export const Track = {
Source: {
Camera: 'camera',
Microphone: 'microphone',
ScreenShare: 'screen_share',
ScreenShareAudio: 'screen_share_audio',
},
} as const;
/** Дорожка: помнит, к каким элементам её подключили. */
export class FakeTrack {
readonly kind: 'audio' | 'video';
readonly attached: HTMLMediaElement[] = [];
constructor(kind: 'audio' | 'video') {
this.kind = kind;
}
attach(element: HTMLMediaElement): HTMLMediaElement {
this.attached.push(element);
return element;
}
detach(element: HTMLMediaElement): HTMLMediaElement[] {
const index = this.attached.indexOf(element);
if (index >= 0) {
this.attached.splice(index, 1);
}
return [];
}
}
export class FakePublication {
constructor(
readonly source: string,
readonly track: FakeTrack,
public isMuted: boolean,
) {}
}
type Handler = (...args: never[]) => void;
/** Участник комнаты: публикации, флаги и записанные вызовы SDK. */
export class FakeParticipant {
isSpeaking = false;
connectionQuality: string = ConnectionQuality.Excellent;
identity: string;
name: string;
readonly isLocal: boolean;
/** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */
readonly calls: { method: string; value: unknown }[] = [];
private readonly publications = new Map<string, FakePublication>();
constructor(
private readonly room: FakeRoom,
identity: string,
name: string,
isLocal = false,
) {
this.identity = identity;
this.name = name;
this.isLocal = isLocal;
}
get isMicrophoneEnabled(): boolean {
return this.isLive(Track.Source.Microphone);
}
get isCameraEnabled(): boolean {
return this.isLive(Track.Source.Camera);
}
get isScreenShareEnabled(): boolean {
return this.isLive(Track.Source.ScreenShare);
}
getTrackPublication(source: string): FakePublication | undefined {
return this.publications.get(source);
}
/** Публикация дорожки: используется и для своих, и для чужих участников. */
setPublication(source: string, muted = false): FakePublication {
const publication = new FakePublication(
source,
new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'),
muted,
);
this.publications.set(source, publication);
return publication;
}
removePublication(source: string): void {
this.publications.delete(source);
this.room.emit(RoomEvent.TrackUnsubscribed);
}
setSpeaking(speaking: boolean): void {
this.isSpeaking = speaking;
}
private isLive(source: string): boolean {
const publication = this.publications.get(source);
return publication !== undefined && !publication.isMuted;
}
private setLocal(source: string, enabled: boolean, method: string): void {
this.calls.push({ method, value: enabled });
const existing = this.publications.get(source);
const track =
existing?.track ?? new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video');
this.publications.set(source, new FakePublication(source, track, !enabled));
this.room.emit(RoomEvent.LocalTrackPublished);
}
setMicrophoneEnabled(enabled: boolean): Promise<void> {
this.setLocal(Track.Source.Microphone, enabled, 'setMicrophoneEnabled');
return Promise.resolve();
}
setCameraEnabled(enabled: boolean): Promise<void> {
this.setLocal(Track.Source.Camera, enabled, 'setCameraEnabled');
return Promise.resolve();
}
setScreenShareEnabled(enabled: boolean): Promise<void> {
this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled');
return Promise.resolve();
}
}
export interface RemoteOptions {
muted?: boolean;
camera?: boolean;
screen?: boolean;
quality?: string;
}
/** Подставная комната: те же методы, что использует `lib/livekit.ts`. */
export class FakeRoom {
static instances: FakeRoom[] = [];
static reset(): void {
FakeRoom.instances = [];
}
static latest(): FakeRoom {
const room = FakeRoom.instances.at(-1);
if (room === undefined) {
throw new Error('комната LiveKit ещё не создавалась');
}
return room;
}
readonly localParticipant: FakeParticipant;
readonly remoteParticipants = new Map<string, FakeParticipant>();
readonly connectCalls: { url: string; token: string }[] = [];
readonly deviceSwitches: { kind: string; deviceId: string }[] = [];
disconnectCalls = 0;
startAudioCalls = 0;
private readonly handlers = new Map<string, Set<Handler>>();
constructor(readonly options?: unknown) {
FakeRoom.instances.push(this);
this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true);
}
on(event: string, handler: Handler): this {
const handlers = this.handlers.get(event) ?? new Set<Handler>();
handlers.add(handler);
this.handlers.set(event, handlers);
return this;
}
off(event: string, handler: Handler): this {
this.handlers.get(event)?.delete(handler);
return this;
}
removeAllListeners(): this {
this.handlers.clear();
return this;
}
connect(url: string, token: string): Promise<void> {
this.connectCalls.push({ url, token });
return Promise.resolve();
}
disconnect(): Promise<void> {
this.disconnectCalls += 1;
this.emit(RoomEvent.Disconnected);
return Promise.resolve();
}
startAudio(): Promise<void> {
this.startAudioCalls += 1;
return Promise.resolve();
}
switchActiveDevice(kind: string, deviceId: string): Promise<boolean> {
this.deviceSwitches.push({ kind, deviceId });
return Promise.resolve(true);
}
emit(event: string, ...args: unknown[]): void {
for (const handler of [...(this.handlers.get(event) ?? [])]) {
(handler as (...values: unknown[]) => void)(...args);
}
}
/** Подключает удалённого участника: как `ParticipantConnected` из SDK. */
addRemote(userId: string, name: string, options: RemoteOptions = {}): FakeParticipant {
const participant = new FakeParticipant(this, userId, name);
participant.connectionQuality = options.quality ?? ConnectionQuality.Excellent;
participant.setPublication(Track.Source.Microphone, options.muted ?? false);
if (options.camera === true) {
participant.setPublication(Track.Source.Camera);
}
if (options.screen === true) {
participant.setPublication(Track.Source.ScreenShare);
}
this.remoteParticipants.set(userId, participant);
this.emit(RoomEvent.ParticipantConnected, participant);
this.emit(RoomEvent.TrackSubscribed);
return participant;
}
removeRemote(userId: string): void {
const participant = this.remoteParticipants.get(userId);
if (participant === undefined) {
return;
}
this.remoteParticipants.delete(userId);
this.emit(RoomEvent.ParticipantDisconnected, participant);
}
/** Кто-то заговорил: `ActiveSpeakersChanged` из SDK. */
activeSpeakers(userIds: string[]): void {
const speakerIds = new Set(userIds);
this.localParticipant.setSpeaking(speakerIds.has(this.localParticipant.identity));
for (const participant of this.remoteParticipants.values()) {
participant.setSpeaking(speakerIds.has(participant.identity));
}
this.emit(RoomEvent.ActiveSpeakersChanged, []);
}
setQuality(userId: string, quality: string): void {
const participant = this.remoteParticipants.get(userId);
if (participant !== undefined) {
participant.connectionQuality = quality;
this.emit(RoomEvent.ConnectionQualityChanged, participant);
}
}
}