Files
grendervill 3fe634c9bc feat(web): второй ряд дашборда и пинг участников в голосовой комнате
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер
  инстанса» (лимиты профиля) и «Сервер целиком» (вся машина);
- голосовая комната: рядом с качеством связи видно пинг участника
  («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT
  измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit
  (`glchat.latency`, lossy), поэтому пинг виден для каждого участника;
- статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был
  недоступен (отчёт читался только с видео);
- в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»:
  выключенный не опрашивает SDK и не публикует своё значение;
- тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника,
  публикация по служебной теме, выключенный пинг не дёргает SDK.
2026-09-20 22:10:17 +03:00

343 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Подставной `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',
DataReceived: 'dataReceived',
} as const;
export const ConnectionQuality = {
Excellent: 'excellent',
Good: 'good',
Poor: 'poor',
Lost: 'lost',
Unknown: 'unknown',
} as const;
/** Уровни качества публикации: значения совпадают с `VideoQuality` из SDK. */
export const VideoQuality = {
LOW: 0,
MEDIUM: 1,
HIGH: 2,
} 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[] = [];
/** Качество публикации, выставленное `setPublishingQuality` (тесты ждут). */
readonly publishingQuality: number[] = [];
/** Сколько раз спросили отчёт WebRTC: по нему проверяют опрос статистики. */
statsRequests = 0;
private readonly report: () => [string, unknown][];
constructor(kind: 'audio' | 'video', report: () => [string, unknown][] = () => []) {
this.kind = kind;
this.report = report;
}
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 [];
}
/** Отчёт WebRTC: `Map`, как настоящий `RTCStatsReport`. */
getRTCStatsReport(): Promise<Map<string, unknown>> {
this.statsRequests += 1;
return Promise.resolve(new Map(this.report()));
}
/** Смена качества публикации: SDK-метод `LocalVideoTrack`. */
setPublishingQuality(quality: number): void {
this.publishingQuality.push(quality);
}
}
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 }[] = [];
/** Отправленные data-пакеты: `{ payload, options }` каждого publishData. */
readonly data: { payload: Uint8Array; options: unknown }[] = [];
/** Параметры захвата экрана: `{ enabled, options }` каждого вызова. */
readonly screenOptions: { enabled: boolean; options: 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, this.newTrack(source), muted);
this.publications.set(source, publication);
return publication;
}
/** Дорожка знает про отчёт WebRTC комнаты: тесты задают его через `setStats`. */
private newTrack(source: string): FakeTrack {
return new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video', () =>
this.room.statsEntries(),
);
}
removePublication(source: string): void {
this.publications.delete(source);
this.room.emit(RoomEvent.TrackUnsubscribed);
}
/** Отправка data-сообщения: тесты проверяют тему, payload и надёжность. */
publishData(payload: Uint8Array, options?: unknown): Promise<void> {
this.data.push({ payload, options });
return Promise.resolve();
}
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 ?? this.newTrack(source);
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();
}
/** Демонстрация экрана: параметры захвата (разрешение и fps) запоминаем. */
setScreenShareEnabled(enabled: boolean, options?: unknown): Promise<void> {
this.screenOptions.push({ enabled, options });
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;
/** Отчёт WebRTC: пары «ключ → объект», как у `RTCStatsReport`. */
private stats: [string, unknown][] = [];
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);
}
/** Отчёт WebRTC, который отдаёт `getRTCStatsReport()` своих дорожек. */
statsEntries(): [string, unknown][] {
return this.stats;
}
setStats(entries: [string, unknown][]): void {
this.stats = entries;
}
/** Эмуляция входящего data-пакета: событие `DataReceived` от участника. */
emitData(payload: Uint8Array, participant: FakeParticipant, topic: string): void {
this.emit(RoomEvent.DataReceived, payload, participant, undefined, topic);
}
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);
}
}
}