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 { 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 { 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, attempts = 6, ): Promise>> { 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 { 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 { await context.addInitScript(() => { const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] }; globalWindow.__pcs = []; const Original = window.RTCPeerConnection; window.RTCPeerConnection = function Patched( ...args: ConstructorParameters ) { 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 { 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; 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 { 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> | null = null; let guest: Awaited> | 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(); } // Аудио реально приходит: ждём ненулевые байты входящего аудио-потока. 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(); } }); });