2026-09-20 19:30:46 +03:00
|
|
|
|
import { createHmac } from 'node:crypto';
|
|
|
|
|
|
import {
|
|
|
|
|
|
expect,
|
|
|
|
|
|
test,
|
|
|
|
|
|
type APIRequestContext,
|
|
|
|
|
|
type Browser,
|
|
|
|
|
|
type BrowserContext,
|
|
|
|
|
|
type Page,
|
|
|
|
|
|
} from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Голосовой e2e: два реальных браузера входят в одну голосовую комнату и
|
|
|
|
|
|
* проверяется, что аудио действительно доходит через LiveKit (AGENT.md 11.2,
|
|
|
|
|
|
* 7.14). Запуск только по явному флагу — тест требует развёрнутого инстанса:
|
|
|
|
|
|
*
|
|
|
|
|
|
* GLCHAT_E2E_VOICE=1 GLCHAT_URL=https://gl.mhspx.su \
|
|
|
|
|
|
* GLCHAT_ADMIN_EMAIL=admin@gl.mhspx.su GLCHAT_ADMIN_PASSWORD=... \
|
|
|
|
|
|
* GLCHAT_ADMIN_TOTP=... PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \
|
|
|
|
|
|
* npx playwright test
|
|
|
|
|
|
*/
|
|
|
|
|
|
const enabled = process.env.GLCHAT_E2E_VOICE === '1';
|
|
|
|
|
|
const ADMIN_EMAIL = process.env.GLCHAT_ADMIN_EMAIL ?? '';
|
|
|
|
|
|
const ADMIN_PASSWORD = process.env.GLCHAT_ADMIN_PASSWORD ?? '';
|
|
|
|
|
|
const ADMIN_TOTP = process.env.GLCHAT_ADMIN_TOTP ?? '';
|
2026-09-20 20:07:10 +03:00
|
|
|
|
/** Постоянный тестовый пользователь: создаётся один раз и переиспользуется. */
|
|
|
|
|
|
const PROBE_LOGIN = 'voiceprobe';
|
2026-09-20 19:30:46 +03:00
|
|
|
|
|
|
|
|
|
|
/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */
|
|
|
|
|
|
function totp(secret: string, now = Date.now()): string {
|
|
|
|
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
|
|
|
|
let bits = '';
|
|
|
|
|
|
for (const symbol of secret.replace(/=+$/u, '').toUpperCase()) {
|
|
|
|
|
|
const index = alphabet.indexOf(symbol);
|
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
bits += index.toString(2).padStart(5, '0');
|
|
|
|
|
|
}
|
|
|
|
|
|
const bytes: number[] = [];
|
|
|
|
|
|
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
|
|
|
|
|
bytes.push(Number.parseInt(bits.slice(i, i + 8), 2));
|
|
|
|
|
|
}
|
|
|
|
|
|
const counter = Math.floor(now / 1000 / 30);
|
|
|
|
|
|
const buffer = Buffer.alloc(8);
|
|
|
|
|
|
buffer.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
|
|
|
|
|
|
buffer.writeUInt32BE(counter % 2 ** 32, 4);
|
|
|
|
|
|
const digest = createHmac('sha1', Buffer.from(bytes)).update(buffer).digest();
|
|
|
|
|
|
const offset = digest.readUInt8(digest.length - 1) & 0x0f;
|
|
|
|
|
|
const value =
|
|
|
|
|
|
((digest.readUInt8(offset) & 0x7f) << 24) |
|
|
|
|
|
|
(digest.readUInt8(offset + 1) << 16) |
|
|
|
|
|
|
(digest.readUInt8(offset + 2) << 8) |
|
|
|
|
|
|
digest.readUInt8(offset + 3);
|
|
|
|
|
|
return (value % 1_000_000).toString().padStart(6, '0');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface Fixture {
|
|
|
|
|
|
guildId: string;
|
|
|
|
|
|
guildName: string;
|
|
|
|
|
|
channelId: string;
|
|
|
|
|
|
guestEmail: string;
|
|
|
|
|
|
guestPassword: string;
|
|
|
|
|
|
adminId: string;
|
|
|
|
|
|
guestId: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* dropStaleGuilds удаляет тестовые серверы прошлых запусков: если прогон упал
|
|
|
|
|
|
* до уборки, они остаются в рейле и мешают выбрать нужный сервер по имени.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function dropStaleGuilds(api: APIRequestContext): Promise<void> {
|
|
|
|
|
|
const response = await api.get('/api/v1/users/@me/guilds');
|
|
|
|
|
|
if (!response.ok()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const { guilds } = (await response.json()) as { guilds: { id: string; name: string }[] };
|
|
|
|
|
|
for (const guild of guilds) {
|
|
|
|
|
|
if (guild.name.startsWith('Voice E2E ')) {
|
|
|
|
|
|
await api.delete(`/api/v1/guilds/${guild.id}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* finishOnboarding помечает первичную настройку пройденной, если она ещё не
|
|
|
|
|
|
* завершена: иначе AuthGuard уводит браузер с `/app` на `/onboarding` и тест
|
|
|
|
|
|
* не доходит до голосовой комнаты. Пустое тело запроса ничего не перезаписывает.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function finishOnboarding(api: APIRequestContext): Promise<void> {
|
|
|
|
|
|
const me = await api.get('/api/v1/users/@me');
|
|
|
|
|
|
expect(me.ok(), `текущий пользователь: ${await me.text()}`).toBeTruthy();
|
|
|
|
|
|
const { user } = (await me.json()) as {
|
|
|
|
|
|
user: { onboarding_completed: boolean; username: string; display_name?: string | null };
|
|
|
|
|
|
};
|
|
|
|
|
|
if (user.onboarding_completed) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const done = await api.post('/api/v1/users/@me/onboarding/complete', {
|
|
|
|
|
|
data: { display_name: user.display_name ?? user.username },
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 19:54:21 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* loginAsAdmin логинит api-контекст под инстанс-администратором и возвращает
|
|
|
|
|
|
* его профиль. Код 2FA одноразовый в пределах окна, поэтому администратор
|
|
|
|
|
|
* логинится ровно один раз — отдельным контекстом, который гостевые действия
|
|
|
|
|
|
* не переключают на другого пользователя.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
|
2026-09-20 20:07:10 +03:00
|
|
|
|
const response = await postWithRetry(api, '/api/v1/auth/login', {
|
|
|
|
|
|
email: ADMIN_EMAIL,
|
|
|
|
|
|
password: ADMIN_PASSWORD,
|
|
|
|
|
|
totp_code: totp(ADMIN_TOTP),
|
2026-09-20 19:30:46 +03:00
|
|
|
|
});
|
2026-09-20 19:54:21 +03:00
|
|
|
|
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
|
|
|
|
|
|
const payload = (await response.json()) as { user: { id: string } };
|
|
|
|
|
|
return payload.user;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 20:07:10 +03:00
|
|
|
|
* postWithRetry повторяет запрос, если сработал лимит частоты: сервер отвечает
|
|
|
|
|
|
* `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты,
|
|
|
|
|
|
* которая как раз и обязана работать (AGENT.md 9.4).
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function postWithRetry(
|
|
|
|
|
|
api: APIRequestContext,
|
|
|
|
|
|
path: string,
|
|
|
|
|
|
data: Record<string, unknown>,
|
2026-09-20 20:57:54 +03:00
|
|
|
|
attempts = 6,
|
2026-09-20 20:07:10 +03:00
|
|
|
|
): Promise<Awaited<ReturnType<APIRequestContext['post']>>> {
|
|
|
|
|
|
let response = await api.post(path, { data });
|
|
|
|
|
|
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
|
|
|
|
|
|
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number };
|
|
|
|
|
|
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 15_000);
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, wait + 200));
|
|
|
|
|
|
response = await api.post(path, { data });
|
|
|
|
|
|
}
|
|
|
|
|
|
return response;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ensureProbeGuest логинит постоянного тестового пользователя, а если его ещё
|
|
|
|
|
|
* нет — регистрирует. Один и тот же аккаунт переиспользуется между прогонами:
|
|
|
|
|
|
* иначе каждый запуск оставлял бы в базе нового пользователя, а удалять
|
|
|
|
|
|
* пользователей API пока не умеет (это задача админ-панели Фазы 5).
|
2026-09-20 20:57:54 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Важно различать «пользователя нет» (401 — тогда регистрируем) и «сработал
|
|
|
|
|
|
* лимит частоты» (429 — ждём и повторяем, иначе тест падал бы из-за защиты,
|
|
|
|
|
|
* которая обязана работать).
|
2026-09-20 20:07:10 +03:00
|
|
|
|
*/
|
|
|
|
|
|
async function ensureProbeGuest(
|
|
|
|
|
|
guest: APIRequestContext,
|
|
|
|
|
|
): Promise<{ id: string; email: string; password: string }> {
|
|
|
|
|
|
const email = `${PROBE_LOGIN}@gl.mhspx.su`;
|
|
|
|
|
|
const password = 'Voice-Probe-Password-9x';
|
2026-09-20 20:57:54 +03:00
|
|
|
|
const credentials = { email, password };
|
|
|
|
|
|
let login = await postWithRetry(guest, '/api/v1/auth/login', credentials);
|
|
|
|
|
|
if (login.status() === 429) {
|
|
|
|
|
|
throw new Error(`вход тестового пользователя упёрся в лимит частоты: ${await login.text()}`);
|
2026-09-20 20:07:10 +03:00
|
|
|
|
}
|
2026-09-20 20:57:54 +03:00
|
|
|
|
if (!login.ok()) {
|
|
|
|
|
|
const registered = await postWithRetry(guest, '/api/v1/auth/register', {
|
|
|
|
|
|
username: PROBE_LOGIN,
|
|
|
|
|
|
...credentials,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(
|
|
|
|
|
|
registered.ok() || registered.status() === 409,
|
|
|
|
|
|
`тестовый пользователь ${PROBE_LOGIN} не зарегистрирован: ${await registered.text()}`,
|
|
|
|
|
|
).toBeTruthy();
|
|
|
|
|
|
// 409 — аккаунт уже существует (например, гонка прогонов): просто входим.
|
|
|
|
|
|
login = await postWithRetry(guest, '/api/v1/auth/login', credentials);
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(login.ok(), `вход тестового пользователя: ${await login.text()}`).toBeTruthy();
|
|
|
|
|
|
const payload = (await login.json()) as { user: { id: string } };
|
2026-09-20 20:07:10 +03:00
|
|
|
|
await finishOnboarding(guest);
|
|
|
|
|
|
return { id: payload.user.id, email, password };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* prepare создаёт сервер и голосовую комнату, приглашает постоянного тестового
|
|
|
|
|
|
* пользователя. `admin` остаётся сессией администратора (модерация и уборка),
|
2026-09-20 19:54:21 +03:00
|
|
|
|
* `guest` — сессией приглашённого пользователя.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function prepare(admin: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
|
|
|
|
|
|
const adminUser = await loginAsAdmin(admin);
|
|
|
|
|
|
await finishOnboarding(admin);
|
|
|
|
|
|
await dropStaleGuilds(admin);
|
2026-09-20 19:30:46 +03:00
|
|
|
|
|
|
|
|
|
|
const stamp = Date.now();
|
|
|
|
|
|
const guildName = `Voice E2E ${stamp}`;
|
2026-09-20 19:54:21 +03:00
|
|
|
|
const guild = await admin.post('/api/v1/guilds', { data: { name: guildName } });
|
2026-09-20 19:30:46 +03:00
|
|
|
|
expect(guild.ok(), `создание сервера: ${await guild.text()}`).toBeTruthy();
|
|
|
|
|
|
const guildId = ((await guild.json()) as { guild: { id: string } }).guild.id;
|
|
|
|
|
|
|
2026-09-20 19:54:21 +03:00
|
|
|
|
const channel = await admin.post(`/api/v1/guilds/${guildId}/channels`, {
|
2026-09-20 19:30:46 +03:00
|
|
|
|
data: { name: 'Голос', type: 'voice' },
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(channel.ok(), `создание комнаты: ${await channel.text()}`).toBeTruthy();
|
|
|
|
|
|
const channelId = ((await channel.json()) as { channel: { id: string } }).channel.id;
|
|
|
|
|
|
|
2026-09-20 19:54:21 +03:00
|
|
|
|
const invite = await admin.post(`/api/v1/guilds/${guildId}/invites`, {
|
2026-09-20 19:30:46 +03:00
|
|
|
|
data: { max_uses: 1, max_age_seconds: 600 },
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
|
|
|
|
|
|
const code = ((await invite.json()) as { invite: { code: string } }).invite.code;
|
|
|
|
|
|
|
2026-09-20 20:07:10 +03:00
|
|
|
|
const probe = await ensureProbeGuest(guest);
|
2026-09-20 19:54:21 +03:00
|
|
|
|
const accept = await guest.post(`/api/v1/invites/${code}`);
|
2026-09-20 19:30:46 +03:00
|
|
|
|
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
guildId,
|
|
|
|
|
|
guildName,
|
|
|
|
|
|
channelId,
|
2026-09-20 20:07:10 +03:00
|
|
|
|
guestEmail: probe.email,
|
|
|
|
|
|
guestPassword: probe.password,
|
2026-09-20 19:54:21 +03:00
|
|
|
|
adminId: adminUser.id,
|
2026-09-20 20:07:10 +03:00
|
|
|
|
guestId: probe.id,
|
2026-09-20 19:30:46 +03:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** instrumentPeerConnections собирает RTC-статистику страницы. */
|
|
|
|
|
|
async function instrumentPeerConnections(context: BrowserContext): Promise<void> {
|
|
|
|
|
|
await context.addInitScript(() => {
|
|
|
|
|
|
const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] };
|
|
|
|
|
|
globalWindow.__pcs = [];
|
|
|
|
|
|
const Original = window.RTCPeerConnection;
|
|
|
|
|
|
window.RTCPeerConnection = function Patched(
|
|
|
|
|
|
...args: ConstructorParameters<typeof RTCPeerConnection>
|
|
|
|
|
|
) {
|
|
|
|
|
|
const pc = new Original(...args);
|
|
|
|
|
|
globalWindow.__pcs?.push(pc);
|
|
|
|
|
|
return pc;
|
|
|
|
|
|
} as unknown as typeof RTCPeerConnection;
|
|
|
|
|
|
window.RTCPeerConnection.prototype = Original.prototype;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** inboundAudioBytes суммирует полученные аудио-байты по всем соединениям. */
|
|
|
|
|
|
async function inboundAudioBytes(page: Page): Promise<number> {
|
|
|
|
|
|
return page.evaluate(async () => {
|
|
|
|
|
|
const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] };
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
for (const pc of globalWindow.__pcs ?? []) {
|
|
|
|
|
|
const stats = await pc.getStats();
|
|
|
|
|
|
stats.forEach((raw) => {
|
|
|
|
|
|
// Отчёты RTC приходят нетипизированными: приводим к словарю и читаем поля.
|
|
|
|
|
|
const report = raw as Record<string, unknown>;
|
|
|
|
|
|
if (report['type'] === 'inbound-rtp' && report['kind'] === 'audio') {
|
|
|
|
|
|
total += Number(report['bytesReceived'] ?? 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return total;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** signIn логинит браузерный контекст и открывает приложение. */
|
|
|
|
|
|
async function signIn(
|
|
|
|
|
|
browser: Browser,
|
|
|
|
|
|
options: { email: string; password: string; totp?: string },
|
|
|
|
|
|
): Promise<{ context: BrowserContext; page: Page }> {
|
|
|
|
|
|
const context = await browser.newContext({ permissions: ['microphone'] });
|
|
|
|
|
|
await instrumentPeerConnections(context);
|
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
|
await page.goto('/login');
|
|
|
|
|
|
await page.getByLabel('Почта', { exact: true }).fill(options.email);
|
|
|
|
|
|
await page.getByLabel('Пароль', { exact: true }).fill(options.password);
|
2026-09-20 20:07:10 +03:00
|
|
|
|
// Лимит частоты входов (5/мин) может сработать на серии прогонов: тогда
|
|
|
|
|
|
// форма показывает ошибку — ждём и пробуем снова со свежим кодом 2FA.
|
|
|
|
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
|
|
|
|
if (options.totp !== undefined) {
|
|
|
|
|
|
await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp));
|
|
|
|
|
|
}
|
|
|
|
|
|
await page.getByRole('button', { name: /Войти/u }).click();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await expect(page).toHaveURL(/\/app/u, { timeout: 20_000 });
|
|
|
|
|
|
return { context, page };
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (attempt === 3) {
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(6_000);
|
|
|
|
|
|
}
|
2026-09-20 19:30:46 +03:00
|
|
|
|
}
|
2026-09-20 20:07:10 +03:00
|
|
|
|
throw new Error('вход не удался');
|
2026-09-20 19:30:46 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 19:54:21 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* joinVoice нажимает «Войти» в голосовой комнате. Кнопка исчезает в тот же
|
|
|
|
|
|
* момент, когда приходит состояние «Подключено», поэтому обычный click может
|
|
|
|
|
|
* зависнуть на проверке стабильности элемента: жмём без проверок и дожидаемся
|
|
|
|
|
|
* фактического результата — кнопки входа больше нет.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function joinVoice(page: Page): Promise<void> {
|
|
|
|
|
|
const button = page.getByTestId('voice-join');
|
|
|
|
|
|
await button.waitFor({ state: 'visible', timeout: 20_000 });
|
|
|
|
|
|
await button.click({ force: true, timeout: 10_000 }).catch(() => undefined);
|
|
|
|
|
|
await expect(button).toBeHidden({ timeout: 30_000 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 19:30:46 +03:00
|
|
|
|
test.describe('голос: два клиента в одной комнате', () => {
|
|
|
|
|
|
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_VOICE=1');
|
|
|
|
|
|
test.skip(ADMIN_EMAIL === '' || ADMIN_PASSWORD === '', 'нужны GLCHAT_ADMIN_EMAIL/PASSWORD/TOTP');
|
|
|
|
|
|
|
|
|
|
|
|
test('аудио доходит через LiveKit, модерация применяется', async ({ browser, playwright }) => {
|
2026-09-20 19:54:21 +03:00
|
|
|
|
const baseURL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
|
|
|
|
|
|
const adminApi = await playwright.request.newContext({ baseURL });
|
|
|
|
|
|
const guestApi = await playwright.request.newContext({ baseURL });
|
|
|
|
|
|
const fixture = await prepare(adminApi, guestApi);
|
2026-09-20 19:30:46 +03:00
|
|
|
|
let admin: Awaited<ReturnType<typeof signIn>> | null = null;
|
|
|
|
|
|
let guest: Awaited<ReturnType<typeof signIn>> | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
admin = await signIn(browser, {
|
|
|
|
|
|
email: ADMIN_EMAIL,
|
|
|
|
|
|
password: ADMIN_PASSWORD,
|
|
|
|
|
|
totp: ADMIN_TOTP,
|
|
|
|
|
|
});
|
|
|
|
|
|
guest = await signIn(browser, {
|
|
|
|
|
|
email: fixture.guestEmail,
|
|
|
|
|
|
password: fixture.guestPassword,
|
|
|
|
|
|
});
|
|
|
|
|
|
const adminSession = admin;
|
|
|
|
|
|
const guestSession = guest;
|
|
|
|
|
|
|
|
|
|
|
|
// Оба клиента открывают голосовую комнату и подключаются.
|
|
|
|
|
|
for (const session of [adminSession, guestSession]) {
|
|
|
|
|
|
await session.page
|
|
|
|
|
|
.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true })
|
|
|
|
|
|
.click();
|
|
|
|
|
|
await session.page.getByRole('link', { name: /Открыть комнату Голос/u }).click();
|
2026-09-20 19:54:21 +03:00
|
|
|
|
await joinVoice(session.page);
|
2026-09-20 19:30:46 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Каждый видит обоих участников.
|
2026-09-20 19:54:21 +03:00
|
|
|
|
for (const session of [adminSession, guestSession]) {
|
2026-09-20 19:30:46 +03:00
|
|
|
|
await expect(session.page.getByTestId(`voice-tile-${fixture.adminId}`)).toBeVisible();
|
|
|
|
|
|
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Аудио реально приходит: ждём ненулевые байты входящего аудио-потока.
|
|
|
|
|
|
for (const session of [adminSession, guestSession]) {
|
|
|
|
|
|
await expect
|
|
|
|
|
|
.poll(async () => inboundAudioBytes(session.page), { timeout: 60_000, intervals: [1000] })
|
|
|
|
|
|
.toBeGreaterThan(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Серверный мьют гостя применяется на стороне SFU.
|
2026-09-20 19:54:21 +03:00
|
|
|
|
const mute = await adminApi.patch(
|
2026-09-20 19:30:46 +03:00
|
|
|
|
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
|
|
|
|
|
|
{
|
|
|
|
|
|
data: { server_mute: true },
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(mute.ok(), `серверный мьют: ${await mute.text()}`).toBeTruthy();
|
|
|
|
|
|
// Кнопка микрофона у гостя блокируется серверным мьютом.
|
|
|
|
|
|
const guestMic = guestSession.page
|
|
|
|
|
|
.getByRole('group', { name: /Управление голосом/u })
|
|
|
|
|
|
.getByRole('button')
|
|
|
|
|
|
.first();
|
|
|
|
|
|
await expect(guestMic).toBeDisabled({ timeout: 30_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// Отключение участника от голосовой комнаты убирает его из списка.
|
2026-09-20 19:54:21 +03:00
|
|
|
|
const drop = await adminApi.delete(
|
2026-09-20 19:30:46 +03:00
|
|
|
|
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(drop.ok(), `отключение от голосовой: ${await drop.text()}`).toBeTruthy();
|
|
|
|
|
|
await expect(guestSession.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeHidden({
|
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(adminSession.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeHidden({
|
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
// Уборка: тестовый сервер удаляется вместе с комнатами и состояниями.
|
2026-09-20 19:54:21 +03:00
|
|
|
|
await adminApi.delete(`/api/v1/guilds/${fixture.guildId}`);
|
2026-09-20 19:30:46 +03:00
|
|
|
|
await admin?.context.close();
|
|
|
|
|
|
await guest?.context.close();
|
2026-09-20 19:54:21 +03:00
|
|
|
|
await adminApi.dispose();
|
|
|
|
|
|
await guestApi.dispose();
|
2026-09-20 19:30:46 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|