import { readFileSync } from 'node:fs'; import { expect, test, type BrowserContext, type Page } from '@playwright/test'; /** * Голосовая нагрузка (AGENT.md 9.1, 11.4): десять участников в одной комнате * и внешний 1080p60-поток. Сессии берутся из файла, который готовит команда * обслуживания (`glchat create-user --sessions`), — вход через форму упёрся бы * в лимит попыток по IP, рассчитанный на защиту от перебора. * * GLCHAT_E2E_VOICE_TEN=1 GLCHAT_URL=https://gl.mhspx.su \ * GLCHAT_LOADTEST_CREDS=../../build/loadtest-users.json \ * GLCHAT_LOADTEST_FIXTURE=../../build/loadtest-fixture.json \ * npx playwright test e2e/voice-ten.spec.ts */ const enabled = process.env.GLCHAT_E2E_VOICE_TEN === '1'; const CREDS_PATH = process.env.GLCHAT_LOADTEST_CREDS ?? '../../build/loadtest-users.json'; const FIXTURE_PATH = process.env.GLCHAT_LOADTEST_FIXTURE ?? '../../build/loadtest-fixture.json'; const PARTICIPANTS = Number(process.env.GLCHAT_VOICE_PARTICIPANTS ?? '10'); const EXPECT_SCREENSHARE = process.env.GLCHAT_EXPECT_SCREENSHARE === '1'; interface Account { id: string; username: string; token: string; } interface Fixture { guildId: string; voiceChannelId: string; } function readAccounts(): Account[] { const raw = JSON.parse(readFileSync(CREDS_PATH, 'utf8')) as Account[]; return raw.filter((item) => item.token !== ''); } function readFixture(): Fixture { return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Fixture; } /** joinVoice нажимает «Войти» в голосовой комнате и ждёт подключения. */ async function joinVoice(page: Page): Promise { const button = page.getByTestId('voice-join'); await button.waitFor({ state: 'visible', timeout: 30_000 }); await button.click({ force: true, timeout: 10_000 }).catch(() => undefined); await expect(button).toBeHidden({ timeout: 60_000 }); } /** 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) => { const report = raw as Record; if (report['type'] === 'inbound-rtp' && report['kind'] === 'audio') { total += Number(report['bytesReceived'] ?? 0); } }); } return total; }); } interface VideoStats { width: number; height: number; fps: number; decoded: number; bytes: number; } /** inboundVideoStats отдаёт лучший входящий видеопоток: размер, кадры и байты. */ async function inboundVideoStats(page: Page): Promise { return page.evaluate(async () => { const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] }; let best = { width: 0, height: 0, fps: 0, decoded: 0, bytes: 0 }; for (const pc of globalWindow.__pcs ?? []) { const stats = await pc.getStats(); stats.forEach((raw) => { const report = raw as Record; if (report['type'] !== 'inbound-rtp' || report['kind'] !== 'video') { return; } const width = Number(report['frameWidth'] ?? 0); const height = Number(report['frameHeight'] ?? 0); const fps = Number(report['framesPerSecond'] ?? 0); const decoded = Number(report['framesDecoded'] ?? 0); const bytes = Number(report['bytesReceived'] ?? 0); if (width * height > best.width * best.height) { best = { width, height, fps, decoded, bytes }; } }); } return best; }); } /** instrumentPeerConnections собирает соединения страницы для статистики. */ 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; }); } test.describe('голосовая нагрузка: десять участников', () => { test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_VOICE_TEN=1'); test('десять клиентов видят друг друга, аудио и 1080p60 доходят', async ({ browser }) => { test.setTimeout(300_000); const baseURL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; const accounts = readAccounts().slice(0, PARTICIPANTS); const fixture = readFixture(); expect(accounts.length, 'нужны аккаунты с сессиями').toBeGreaterThanOrEqual(PARTICIPANTS); const sessions: { account: Account; context: BrowserContext; page: Page }[] = []; try { for (const account of accounts) { const context = await browser.newContext({ permissions: ['microphone', 'camera'], baseURL, }); await instrumentPeerConnections(context); await context.addCookies([ { name: '__Host-session', value: account.token, url: baseURL, httpOnly: true, secure: true, }, ]); const page = await context.newPage(); await page.goto(`/app/${fixture.guildId}/${fixture.voiceChannelId}`); await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 }); sessions.push({ account, context, page }); } // Все десять входят в комнату. for (const session of sessions) { await joinVoice(session.page); } console.log(`участников в комнате: ${sessions.length}`); // Каждый видит плитки всех участников: это состояние LiveKit, а не список сервера. for (const session of sessions.slice(0, 2)) { for (const other of sessions) { await expect(session.page.getByTestId(`voice-tile-${other.account.id}`)).toBeVisible({ timeout: 60_000, }); } } // Аудио реально доходит: у двух клиентов считаем входящие аудио-байты. for (const session of sessions.slice(0, 2)) { await expect .poll(async () => inboundAudioBytes(session.page), { timeout: 90_000, intervals: [1000] }) .toBeGreaterThan(0); } if (EXPECT_SCREENSHARE) { // Внешний 1080p60-поток (lk room join с IVF) должен дойти до клиента. // Adaptive stream ставит видео на паузу, пока плитка не на экране, а // плитки внешнего издателя в интерфейсе может не быть вовсе (он не // проходит через приложение). Поэтому здесь проверяется, что слой // 1080p60 доехал до клиента (размер кадра и байты), а реальное // декодирование 60 кадров в секунду меряет независимый подписчик — // build/verify-1080p60-browser.mjs (adaptive stream выключен). await expect .poll(async () => inboundVideoStats(sessions[0].page), { timeout: 90_000, intervals: [2000], }) .toMatchObject({ width: 1920, height: 1080 }); const stats = await inboundVideoStats(sessions[0].page); console.log('входящее видео:', JSON.stringify(stats)); await expect .poll(async () => (await inboundVideoStats(sessions[0].page)).bytes, { timeout: 60_000, intervals: [2000], }) .toBeGreaterThan(0); } } finally { for (const session of sessions) { await session.context.close(); } } }); });