Files
glchat/web/e2e/voice.spec.ts
T
grendervill 912a067c9a test(web): e2e-стенд Playwright для голосовой комнаты
Проверка голоса двумя реальными браузерами (AGENT.md 11.2): два контекста с
фейковыми медиа-устройствами входят в одну комнату, инструментируется
`RTCPeerConnection` и по `inbound-rtp` подтверждается, что аудио реально
доходит; затем серверный мьют блокирует микрофон у гостя, а отключение
участника убирает его плитку у обоих клиентов.

- `playwright.config.ts`: прогон против развёрнутого инстанса (`GLCHAT_URL`),
  трейс при падении, флаги фейковых медиа-устройств;
- `e2e/voice.spec.ts`: подготовка через REST (сервер, комната, инвайт, гость),
  завершение онбординга, уборка тестовых серверов и включение только по
  `GLCHAT_E2E_VOICE=1`;
- `tsconfig.e2e.json` и запись в eslint для новых файлов.
2026-09-20 19:30:46 +03:00

296 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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<void> {
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();
}
/** prepare создаёт сервер, голосовую комнату и второго пользователя по приглашению. */
async function prepare(api: APIRequestContext): Promise<Fixture> {
const adminLogin = await api.post('/api/v1/auth/login', {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD, totp_code: totp(ADMIN_TOTP) },
});
expect(adminLogin.ok(), `вход администратора: ${await adminLogin.text()}`).toBeTruthy();
const admin = (await adminLogin.json()) as { user: { id: string } };
await finishOnboarding(api);
await dropStaleGuilds(api);
const stamp = Date.now();
const guildName = `Voice E2E ${stamp}`;
const guild = await api.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 api.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 api.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 api.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;
// Регистрация переключила сессию api-контекста на гостя — доводим его до /app.
await finishOnboarding(api);
const accept = await api.post(`/api/v1/invites/${code}`);
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
return {
guildId,
guildName,
channelId,
guestEmail,
guestPassword,
adminId: admin.user.id,
guestId,
};
}
/** instrumentPeerConnections собирает RTC-статистику страницы. */
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;
});
}
/** 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) => {
// Отчёты RTC приходят нетипизированными: приводим к словарю и читаем поля.
const report = raw as Record<string, unknown>;
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 };
}
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 api = await playwright.request.newContext({
baseURL: process.env.GLCHAT_URL ?? 'https://gl.mhspx.su',
});
const fixture = await prepare(api);
let admin: Awaited<ReturnType<typeof signIn>> | null = null;
let guest: Awaited<ReturnType<typeof signIn>> | 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 session.page.getByTestId('voice-join').click();
}
// Каждый видит обоих участников.
for (const session of [admin, guest]) {
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 api.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 api.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 api.delete(`/api/v1/guilds/${fixture.guildId}`);
await admin?.context.close();
await guest?.context.close();
await api.dispose();
}
});
});