Files
glchat/web/e2e/dm-call.spec.ts
T
grendervill 78e9c3c2d6 test(e2e): звонок в личной беседе двумя браузерами (Фаза 7)
Спека поднимает звонок между двумя реальными клиентами на стенде и
проверяет то, что нельзя проверить юнитами: приглашение приходит событием,
оба браузера обмениваются аудио через LiveKit (входящие байты > 0),
выключенный микрофон виден на сервере, завершение снимает маркер у второго
участника, а в истории остаётся запись.

Сессия берётся из api-контекста (cookie __Host-session): форма входа
упирается в лимит попыток при серии прогонов. Запуск по флагу
GLCHAT_E2E_VOICE=1, как у голосовой спеки.
2026-09-26 17:20:33 +03:00

187 lines
8.7 KiB
TypeScript

import {
expect,
test,
type APIRequestContext,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
import { adminCredentials, ensureProbe, loginAsAdmin, openDmChannel } from './support';
/**
* Звонок в личной беседе: два реальных браузера поднимают звонок через LiveKit и
* проверяется, что аудио действительно доходит (AGENT.md 7.8, 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 e2e/dm-call.spec.ts
*/
const enabled = process.env.GLCHAT_E2E_VOICE === '1';
/** Постоянный тестовый пользователь: создаётся один раз и переиспользуется. */
const PROBE_LOGIN = 'dmcallprobe';
const admin = adminCredentials();
/** 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) => {
const report = raw as Record<string, unknown>;
if (report['type'] === 'inbound-rtp' && report['kind'] === 'audio') {
total += Number(report['bytesReceived'] ?? 0);
}
});
}
return total;
});
}
/**
* openVoiceSession открывает браузерный контекст на уже готовой сессии
* api-контекста: вход выполняется через API (там же живёт 2FA), а браузер
* получает cookie `__Host-session` — так прогон не упирается в форму входа
* и в лимит попыток (AGENT.md 8.6).
*/
async function openVoiceSession(
browser: Browser,
api: APIRequestContext,
baseURL: string,
): Promise<{ context: BrowserContext; page: Page }> {
const state = await api.storageState();
const context = await browser.newContext({ baseURL, permissions: ['microphone'] });
await instrumentPeerConnections(context);
await context.addCookies(state.cookies);
const page = await context.newPage();
await page.goto('/app');
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('аудио доходит, маркер звонка и история работают', 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 });
let adminSession: Awaited<ReturnType<typeof openVoiceSession>> | null = null;
let guestSession: Awaited<ReturnType<typeof openVoiceSession>> | null = null;
try {
const adminUser = await loginAsAdmin(adminApi);
const probe = await ensureProbe(guestApi, PROBE_LOGIN, 'Dm-Call-Probe-Password-9x');
const channelId = await openDmChannel(adminApi, probe.id);
adminSession = await openVoiceSession(browser, adminApi, baseURL);
guestSession = await openVoiceSession(browser, guestApi, baseURL);
const caller = adminSession;
const callee = guestSession;
// Обе стороны открывают беседу: список бесед приходит снапшотом READY.
for (const session of [caller, callee]) {
await session.page.goto(`${baseURL}/app/friends/${channelId}`);
await expect(session.page.getByTestId('dm-header')).toBeVisible({ timeout: 30_000 });
}
// Меню звонка есть в шапке беседы: голос на стенде включён.
await expect(caller.page.getByTestId('dm-call-menu')).toBeVisible();
// Звонящий выбирает аудиозвонок в меню шапки.
await caller.page.getByTestId('dm-call-menu').click();
await caller.page.getByTestId('dm-call-audio').click();
// Приглашение приходит событием: у гостя появляется карточка входящего.
await expect(callee.page.getByTestId('dm-incoming-call')).toBeVisible({ timeout: 30_000 });
await expect(callee.page.getByTestId('dm-call-header-status')).toHaveText('Идёт звонок');
await callee.page.getByTestId('dm-incoming-call-accept').click();
// Оба подключились: панель звонка и плитки участников.
for (const session of [caller, callee]) {
await expect(session.page.getByTestId('dm-call-panel')).toBeVisible({ timeout: 30_000 });
await expect(session.page.getByTestId(`dm-call-tile-${adminUser.id}`)).toBeVisible({
timeout: 30_000,
});
await expect(session.page.getByTestId(`dm-call-tile-${probe.id}`)).toBeVisible({
timeout: 30_000,
});
}
// Аудио реально приходит: ждём ненулевые байты входящего аудио-потока.
for (const session of [caller, callee]) {
await expect
.poll(async () => inboundAudioBytes(session.page), { timeout: 60_000, intervals: [1000] })
.toBeGreaterThan(0);
}
// Микрофон выключается и на сервере: флаг виден в составе звонка.
await callee.page.getByTestId('dm-call-mic').click();
await expect(callee.page.getByTestId('dm-call-mic')).toHaveAttribute('aria-pressed', 'false');
await expect
.poll(
async () => {
const call = await lastCall(adminApi, channelId);
const mine = call?.participants.find((item) => item.user_id === probe.id);
return mine?.self_mute ?? false;
},
{ timeout: 30_000, intervals: [1000] },
)
.toBe(true);
// Завершение звонка: у второго участника панель закрывается, маркер снят.
await caller.page.getByTestId('dm-call-leave').click();
await expect(callee.page.getByTestId('dm-call-panel')).toBeHidden({ timeout: 30_000 });
await expect(callee.page.getByTestId('dm-call-header-status')).toBeHidden({
timeout: 30_000,
});
await expect(callee.page.getByTestId('dm-call-' + channelId)).toBeHidden({ timeout: 30_000 });
// История звонков: запись о разговоре с длительностью.
await caller.page.getByTestId('dm-call-menu').click();
await caller.page.getByTestId('dm-call-history').click();
await expect(caller.page.getByTestId('dm-call-history')).toBeVisible({ timeout: 30_000 });
await expect(caller.page.getByTestId('dm-call-history').locator('li').first()).toBeVisible();
} finally {
await adminSession?.context.close();
await guestSession?.context.close();
await adminApi.dispose();
await guestApi.dispose();
}
});
});
/** lastCall читает последний звонок беседы: по нему видно флаги участников. */
async function lastCall(api: APIRequestContext, channelId: string) {
const response = await api.get(`/api/v1/channels/${channelId}/calls`);
if (!response.ok()) {
return null;
}
const payload = (await response.json()) as {
calls: { participants: { user_id: string; self_mute: boolean }[] }[];
};
return payload.calls[0] ?? null;
}