Files
glchat/web/e2e/voice.spec.ts
T
grendervill 96acc40031 test(web): e2e проверяет пинг участника в плитке
Два реальных браузера в одной комнате: у каждого в плитке появляется своя
задержка («N мс», измеряется по WebRTC-статистике), а второй участник видит
пинг первого, пришедший по data-каналу LiveKit.
2026-09-20 22:15:39 +03:00

408 lines
19 KiB
TypeScript
Raw 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.
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 ?? '';
/** Постоянный тестовый пользователь: создаётся один раз и переиспользуется. */
const PROBE_LOGIN = 'voiceprobe';
/** 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();
}
/**
* loginAsAdmin логинит api-контекст под инстанс-администратором и возвращает
* его профиль. Код 2FA одноразовый в пределах окна, поэтому администратор
* логинится ровно один раз — отдельным контекстом, который гостевые действия
* не переключают на другого пользователя.
*/
async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
const response = await postWithRetry(api, '/api/v1/auth/login', {
email: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
totp_code: totp(ADMIN_TOTP),
});
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { user: { id: string } };
return payload.user;
}
/**
* postWithRetry повторяет запрос, если сработал лимит частоты: сервер отвечает
* `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты,
* которая как раз и обязана работать (AGENT.md 9.4).
*/
async function postWithRetry(
api: APIRequestContext,
path: string,
data: Record<string, unknown>,
attempts = 6,
): 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).
*
* Важно различать «пользователя нет» (401 — тогда регистрируем) и «сработал
* лимит частоты» (429 — ждём и повторяем, иначе тест падал бы из-за защиты,
* которая обязана работать).
*/
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';
const credentials = { email, password };
let login = await postWithRetry(guest, '/api/v1/auth/login', credentials);
if (login.status() === 429) {
throw new Error(`вход тестового пользователя упёрся в лимит частоты: ${await login.text()}`);
}
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 } };
await finishOnboarding(guest);
return { id: payload.user.id, email, password };
}
/**
* prepare создаёт сервер и голосовую комнату, приглашает постоянного тестового
* пользователя. `admin` остаётся сессией администратора (модерация и уборка),
* `guest` — сессией приглашённого пользователя.
*/
async function prepare(admin: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
const adminUser = await loginAsAdmin(admin);
await finishOnboarding(admin);
await dropStaleGuilds(admin);
const stamp = Date.now();
const guildName = `Voice E2E ${stamp}`;
const guild = await admin.post('/api/v1/guilds', { data: { name: guildName } });
expect(guild.ok(), `создание сервера: ${await guild.text()}`).toBeTruthy();
const guildId = ((await guild.json()) as { guild: { id: string } }).guild.id;
const channel = await admin.post(`/api/v1/guilds/${guildId}/channels`, {
data: { name: 'Голос', type: 'voice' },
});
expect(channel.ok(), `создание комнаты: ${await channel.text()}`).toBeTruthy();
const channelId = ((await channel.json()) as { channel: { id: string } }).channel.id;
const invite = await admin.post(`/api/v1/guilds/${guildId}/invites`, {
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;
const probe = await ensureProbeGuest(guest);
const accept = await guest.post(`/api/v1/invites/${code}`);
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
return {
guildId,
guildName,
channelId,
guestEmail: probe.email,
guestPassword: probe.password,
adminId: adminUser.id,
guestId: probe.id,
};
}
/** 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);
// Лимит частоты входов (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);
}
}
throw new Error('вход не удался');
}
/**
* 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 });
}
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 }) => {
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);
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();
await joinVoice(session.page);
}
// Каждый видит обоих участников.
for (const session of [adminSession, guestSession]) {
await expect(session.page.getByTestId(`voice-tile-${fixture.adminId}`)).toBeVisible();
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
}
// Пинг участника виден в плитке и обновляется: своё значение измеряется
// по статистике WebRTC, чужое приходит по data-каналу LiveKit.
for (const session of [adminSession, guestSession]) {
const ownLabel = session.page
.getByTestId(`voice-tile-${session === adminSession ? fixture.adminId : fixture.guestId}`)
.getByTestId('voice-quality-label');
await expect
.poll(async () => (await ownLabel.textContent()) ?? '', { timeout: 30_000 })
.toMatch(/\d+ мс/u);
}
// Каждый видит пинг другого участника.
await expect
.poll(
async () =>
(await adminSession.page
.getByTestId(`voice-tile-${fixture.guestId}`)
.getByTestId('voice-quality-label')
.textContent()) ?? '',
{ timeout: 30_000 },
)
.toMatch(/\d+ мс/u);
// Аудио реально приходит: ждём ненулевые байты входящего аудио-потока.
for (const session of [adminSession, guestSession]) {
await expect
.poll(async () => inboundAudioBytes(session.page), { timeout: 60_000, intervals: [1000] })
.toBeGreaterThan(0);
}
// Серверный мьют гостя применяется на стороне SFU.
const mute = await adminApi.patch(
`/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 });
// Отключение участника от голосовой комнаты убирает его из списка.
const drop = await adminApi.delete(
`/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 {
// Уборка: тестовый сервер удаляется вместе с комнатами и состояниями.
await adminApi.delete(`/api/v1/guilds/${fixture.guildId}`);
await admin?.context.close();
await guest?.context.close();
await adminApi.dispose();
await guestApi.dispose();
}
});
});