78e9c3c2d6
Спека поднимает звонок между двумя реальными клиентами на стенде и проверяет то, что нельзя проверить юнитами: приглашение приходит событием, оба браузера обмениваются аудио через LiveKit (входящие байты > 0), выключенный микрофон виден на сервере, завершение снимает маркер у второго участника, а в истории остаётся запись. Сессия берётся из api-контекста (cookie __Host-session): форма входа упирается в лимит попыток при серии прогонов. Запуск по флагу GLCHAT_E2E_VOICE=1, как у голосовой спеки.
187 lines
8.7 KiB
TypeScript
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;
|
|
}
|