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; }