test(e2e): звонок в личной беседе двумя браузерами (Фаза 7)
Спека поднимает звонок между двумя реальными клиентами на стенде и проверяет то, что нельзя проверить юнитами: приглашение приходит событием, оба браузера обмениваются аудио через LiveKit (входящие байты > 0), выключенный микрофон виден на сервере, завершение снимает маркер у второго участника, а в истории остаётся запись. Сессия берётся из api-контекста (cookie __Host-session): форма входа упирается в лимит попыток при серии прогонов. Запуск по флагу GLCHAT_E2E_VOICE=1, как у голосовой спеки.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user