feat(voice): журнал обработанных вебхуков LiveKit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user