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 ?? ''; /** 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 api.post('/api/v1/auth/login', { data: { 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; } /** * 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 guestLogin = `voice${stamp}`; const guestEmail = `${guestLogin}@gl.mhspx.su`; const guestPassword = 'Voice-Probe-Password-9x'; const guestContext = await guest.post('/api/v1/auth/register', { data: { username: guestLogin, email: guestEmail, password: guestPassword, }, }); expect(guestContext.ok(), `регистрация гостя: ${await guestContext.text()}`).toBeTruthy(); const guestId = ((await guestContext.json()) as { user: { id: string } }).user.id; await finishOnboarding(guest); const accept = await guest.post(`/api/v1/invites/${code}`); expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy(); return { guildId, guildName, channelId, guestEmail, guestPassword, adminId: adminUser.id, guestId, }; } /** 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); if (options.totp !== undefined) { await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp)); } await page.getByRole('button', { name: /Войти/u }).click(); await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 }); return { context, page }; } /** * 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(); } }); });