diff --git a/web/e2e/dm-call.spec.ts b/web/e2e/dm-call.spec.ts new file mode 100644 index 0000000..5620d60 --- /dev/null +++ b/web/e2e/dm-call.spec.ts @@ -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 { + 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; + }); +} + +/** 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; + }); +} + +/** + * 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> | null = null; + let guestSession: Awaited> | 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; +}