2026-09-22 20:00:33 +03:00
|
|
|
import { readFileSync } from 'node:fs';
|
2026-09-22 20:35:13 +03:00
|
|
|
import { expect, test, type BrowserContext, type Page } from '@playwright/test';
|
2026-09-22 20:00:33 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Голосовая нагрузка (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<void> {
|
|
|
|
|
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<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) => {
|
|
|
|
|
const report = raw as Record<string, unknown>;
|
|
|
|
|
if (report['type'] === 'inbound-rtp' && report['kind'] === 'audio') {
|
|
|
|
|
total += Number(report['bytesReceived'] ?? 0);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return total;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 20:35:13 +03:00
|
|
|
interface VideoStats {
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
fps: number;
|
|
|
|
|
decoded: number;
|
|
|
|
|
bytes: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** inboundVideoStats отдаёт лучший входящий видеопоток: размер, кадры и байты. */
|
|
|
|
|
async function inboundVideoStats(page: Page): Promise<VideoStats> {
|
|
|
|
|
return page.evaluate<VideoStats>(async () => {
|
2026-09-22 20:00:33 +03:00
|
|
|
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<string, unknown>;
|
|
|
|
|
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<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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-22 20:35:13 +03:00
|
|
|
.poll(async () => inboundVideoStats(sessions[0].page), {
|
2026-09-22 20:00:33 +03:00
|
|
|
timeout: 90_000,
|
|
|
|
|
intervals: [2000],
|
|
|
|
|
})
|
|
|
|
|
.toMatchObject({ width: 1920, height: 1080 });
|
2026-09-22 20:35:13 +03:00
|
|
|
const stats = await inboundVideoStats(sessions[0].page);
|
2026-09-22 20:00:33 +03:00
|
|
|
console.log('входящее видео:', JSON.stringify(stats));
|
|
|
|
|
await expect
|
2026-09-22 20:35:13 +03:00
|
|
|
.poll(async () => (await inboundVideoStats(sessions[0].page)).bytes, {
|
2026-09-22 20:00:33 +03:00
|
|
|
timeout: 60_000,
|
|
|
|
|
intervals: [2000],
|
|
|
|
|
})
|
|
|
|
|
.toBeGreaterThan(0);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
for (const session of sessions) {
|
|
|
|
|
await session.context.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|