import { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page, } from '@playwright/test'; import { adminCredentials, dropStaleGuilds, ensureProbe, finishOnboarding, loginAsAdmin, signIn, } from './support'; /** * Голосовой 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 PROBE_LOGIN = 'voiceprobe'; const admin = adminCredentials(); interface Fixture { guildId: string; guildName: string; channelId: string; guestEmail: string; guestPassword: string; adminId: string; guestId: string; } /** * prepare создаёт сервер и голосовую комнату, приглашает постоянного тестового * пользователя. `admin` остаётся сессией администратора (модерация и уборка), * `guest` — сессией приглашённого пользователя. */ async function prepare(adminApi: APIRequestContext, guest: APIRequestContext): Promise { const adminUser = await loginAsAdmin(adminApi); await finishOnboarding(adminApi); await dropStaleGuilds(adminApi, 'Voice E2E '); const stamp = Date.now(); const guildName = `Voice E2E ${stamp}`; const guild = await adminApi.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 adminApi.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 adminApi.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 ensureProbe(guest, PROBE_LOGIN, 'Voice-Probe-Password-9x'); 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; }); } /** * signInVoice логинит браузерный контекст с фейковыми медиа-устройствами и * патчем RTCPeerConnection: так проверяется реальная доставка аудио. */ async function signInVoice( browser: Browser, options: { email: string; password: string; totp?: string }, ): Promise<{ context: BrowserContext; page: Page }> { return signIn(browser, options, { contextOptions: { permissions: ['microphone'] }, onContext: instrumentPeerConnections, }); } /** * 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 adminBrowser: Awaited> | null = null; let guestBrowser: Awaited> | null = null; try { adminBrowser = await signInVoice(browser, { email: admin.email, password: admin.password, totp: admin.totp, }); guestBrowser = await signInVoice(browser, { email: fixture.guestEmail, password: fixture.guestPassword, }); const adminSession = adminBrowser; const guestSession = guestBrowser; // Оба клиента открывают голосовую комнату и подключаются. 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-каналу. for (const session of [adminSession, guestSession]) { const ownQuality = session.page .getByTestId(`voice-tile-${session === adminSession ? fixture.adminId : fixture.guestId}`) .getByTestId('voice-quality'); await expect .poll(async () => (await ownQuality.getAttribute('title')) ?? '', { timeout: 30_000 }) .toMatch(/\d+ мс/u); } // Каждый видит пинг другого участника. await expect .poll( async () => (await adminSession.page .getByTestId(`voice-tile-${fixture.guestId}`) .getByTestId('voice-quality') .getAttribute('title')) ?? '', { 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 adminBrowser?.context.close(); await guestBrowser?.context.close(); await adminApi.dispose(); await guestApi.dispose(); } }); });