test(web): e2e-стенд Playwright для голосовой комнаты
Проверка голоса двумя реальными браузерами (AGENT.md 11.2): два контекста с фейковыми медиа-устройствами входят в одну комнату, инструментируется `RTCPeerConnection` и по `inbound-rtp` подтверждается, что аудио реально доходит; затем серверный мьют блокирует микрофон у гостя, а отключение участника убирает его плитку у обоих клиентов. - `playwright.config.ts`: прогон против развёрнутого инстанса (`GLCHAT_URL`), трейс при падении, флаги фейковых медиа-устройств; - `e2e/voice.spec.ts`: подготовка через REST (сервер, комната, инвайт, гость), завершение онбординга, уборка тестовых серверов и включение только по `GLCHAT_E2E_VOICE=1`; - `tsconfig.e2e.json` и запись в eslint для новых файлов.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Голосовой e2e: два реальных браузера входят в одну голосовую комнату и
|
||||
* проверяется, что аудио действительно доходит через LiveKit (AGENT.md 11.2,
|
||||
* 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
|
||||
*/
|
||||
const enabled = process.env.GLCHAT_E2E_VOICE === '1';
|
||||
const ADMIN_EMAIL = process.env.GLCHAT_ADMIN_EMAIL ?? '';
|
||||
const ADMIN_PASSWORD = process.env.GLCHAT_ADMIN_PASSWORD ?? '';
|
||||
const ADMIN_TOTP = process.env.GLCHAT_ADMIN_TOTP ?? '';
|
||||
|
||||
/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */
|
||||
function totp(secret: string, now = Date.now()): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let bits = '';
|
||||
for (const symbol of secret.replace(/=+$/u, '').toUpperCase()) {
|
||||
const index = alphabet.indexOf(symbol);
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
bits += index.toString(2).padStart(5, '0');
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||||
bytes.push(Number.parseInt(bits.slice(i, i + 8), 2));
|
||||
}
|
||||
const counter = Math.floor(now / 1000 / 30);
|
||||
const buffer = Buffer.alloc(8);
|
||||
buffer.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
|
||||
buffer.writeUInt32BE(counter % 2 ** 32, 4);
|
||||
const digest = createHmac('sha1', Buffer.from(bytes)).update(buffer).digest();
|
||||
const offset = digest.readUInt8(digest.length - 1) & 0x0f;
|
||||
const value =
|
||||
((digest.readUInt8(offset) & 0x7f) << 24) |
|
||||
(digest.readUInt8(offset + 1) << 16) |
|
||||
(digest.readUInt8(offset + 2) << 8) |
|
||||
digest.readUInt8(offset + 3);
|
||||
return (value % 1_000_000).toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
guildId: string;
|
||||
guildName: string;
|
||||
channelId: string;
|
||||
guestEmail: string;
|
||||
guestPassword: string;
|
||||
adminId: string;
|
||||
guestId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* dropStaleGuilds удаляет тестовые серверы прошлых запусков: если прогон упал
|
||||
* до уборки, они остаются в рейле и мешают выбрать нужный сервер по имени.
|
||||
*/
|
||||
async function dropStaleGuilds(api: APIRequestContext): Promise<void> {
|
||||
const response = await api.get('/api/v1/users/@me/guilds');
|
||||
if (!response.ok()) {
|
||||
return;
|
||||
}
|
||||
const { guilds } = (await response.json()) as { guilds: { id: string; name: string }[] };
|
||||
for (const guild of guilds) {
|
||||
if (guild.name.startsWith('Voice E2E ')) {
|
||||
await api.delete(`/api/v1/guilds/${guild.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* finishOnboarding помечает первичную настройку пройденной, если она ещё не
|
||||
* завершена: иначе AuthGuard уводит браузер с `/app` на `/onboarding` и тест
|
||||
* не доходит до голосовой комнаты. Пустое тело запроса ничего не перезаписывает.
|
||||
*/
|
||||
async function finishOnboarding(api: APIRequestContext): Promise<void> {
|
||||
const me = await api.get('/api/v1/users/@me');
|
||||
expect(me.ok(), `текущий пользователь: ${await me.text()}`).toBeTruthy();
|
||||
const { user } = (await me.json()) as {
|
||||
user: { onboarding_completed: boolean; username: string; display_name?: string | null };
|
||||
};
|
||||
if (user.onboarding_completed) {
|
||||
return;
|
||||
}
|
||||
const done = await api.post('/api/v1/users/@me/onboarding/complete', {
|
||||
data: { display_name: user.display_name ?? user.username },
|
||||
});
|
||||
expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy();
|
||||
}
|
||||
|
||||
/** prepare создаёт сервер, голосовую комнату и второго пользователя по приглашению. */
|
||||
async function prepare(api: APIRequestContext): Promise<Fixture> {
|
||||
const adminLogin = await api.post('/api/v1/auth/login', {
|
||||
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD, totp_code: totp(ADMIN_TOTP) },
|
||||
});
|
||||
expect(adminLogin.ok(), `вход администратора: ${await adminLogin.text()}`).toBeTruthy();
|
||||
const admin = (await adminLogin.json()) as { user: { id: string } };
|
||||
await finishOnboarding(api);
|
||||
await dropStaleGuilds(api);
|
||||
|
||||
const stamp = Date.now();
|
||||
const guildName = `Voice E2E ${stamp}`;
|
||||
const guild = await api.post('/api/v1/guilds', { data: { name: guildName } });
|
||||
expect(guild.ok(), `создание сервера: ${await guild.text()}`).toBeTruthy();
|
||||
const guildId = ((await guild.json()) as { guild: { id: string } }).guild.id;
|
||||
|
||||
const channel = await api.post(`/api/v1/guilds/${guildId}/channels`, {
|
||||
data: { name: 'Голос', type: 'voice' },
|
||||
});
|
||||
expect(channel.ok(), `создание комнаты: ${await channel.text()}`).toBeTruthy();
|
||||
const channelId = ((await channel.json()) as { channel: { id: string } }).channel.id;
|
||||
|
||||
const invite = await api.post(`/api/v1/guilds/${guildId}/invites`, {
|
||||
data: { max_uses: 1, max_age_seconds: 600 },
|
||||
});
|
||||
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
|
||||
const code = ((await invite.json()) as { invite: { code: string } }).invite.code;
|
||||
|
||||
const guestLogin = `voice${stamp}`;
|
||||
const guestEmail = `${guestLogin}@gl.mhspx.su`;
|
||||
const guestPassword = 'Voice-Probe-Password-9x';
|
||||
const guestContext = await api.post('/api/v1/auth/register', {
|
||||
data: {
|
||||
username: guestLogin,
|
||||
email: guestEmail,
|
||||
password: guestPassword,
|
||||
},
|
||||
});
|
||||
expect(guestContext.ok(), `регистрация гостя: ${await guestContext.text()}`).toBeTruthy();
|
||||
const guestId = ((await guestContext.json()) as { user: { id: string } }).user.id;
|
||||
// Регистрация переключила сессию api-контекста на гостя — доводим его до /app.
|
||||
await finishOnboarding(api);
|
||||
|
||||
const accept = await api.post(`/api/v1/invites/${code}`);
|
||||
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
|
||||
|
||||
return {
|
||||
guildId,
|
||||
guildName,
|
||||
channelId,
|
||||
guestEmail,
|
||||
guestPassword,
|
||||
adminId: admin.user.id,
|
||||
guestId,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
// Отчёты RTC приходят нетипизированными: приводим к словарю и читаем поля.
|
||||
const report = raw as Record<string, unknown>;
|
||||
if (report['type'] === 'inbound-rtp' && report['kind'] === 'audio') {
|
||||
total += Number(report['bytesReceived'] ?? 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
return total;
|
||||
});
|
||||
}
|
||||
|
||||
/** signIn логинит браузерный контекст и открывает приложение. */
|
||||
async function signIn(
|
||||
browser: Browser,
|
||||
options: { email: string; password: string; totp?: string },
|
||||
): Promise<{ context: BrowserContext; page: Page }> {
|
||||
const context = await browser.newContext({ permissions: ['microphone'] });
|
||||
await instrumentPeerConnections(context);
|
||||
const page = await context.newPage();
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Почта', { exact: true }).fill(options.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(options.password);
|
||||
if (options.totp !== undefined) {
|
||||
await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp));
|
||||
}
|
||||
await page.getByRole('button', { name: /Войти/u }).click();
|
||||
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('аудио доходит через LiveKit, модерация применяется', async ({ browser, playwright }) => {
|
||||
const api = await playwright.request.newContext({
|
||||
baseURL: process.env.GLCHAT_URL ?? 'https://gl.mhspx.su',
|
||||
});
|
||||
const fixture = await prepare(api);
|
||||
let admin: Awaited<ReturnType<typeof signIn>> | null = null;
|
||||
let guest: Awaited<ReturnType<typeof signIn>> | null = null;
|
||||
|
||||
try {
|
||||
admin = await signIn(browser, {
|
||||
email: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
totp: ADMIN_TOTP,
|
||||
});
|
||||
guest = await signIn(browser, {
|
||||
email: fixture.guestEmail,
|
||||
password: fixture.guestPassword,
|
||||
});
|
||||
const adminSession = admin;
|
||||
const guestSession = guest;
|
||||
|
||||
// Оба клиента открывают голосовую комнату и подключаются.
|
||||
for (const session of [adminSession, guestSession]) {
|
||||
await session.page
|
||||
.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true })
|
||||
.click();
|
||||
await session.page.getByRole('link', { name: /Открыть комнату Голос/u }).click();
|
||||
await session.page.getByTestId('voice-join').click();
|
||||
}
|
||||
|
||||
// Каждый видит обоих участников.
|
||||
for (const session of [admin, guest]) {
|
||||
await expect(session.page.getByTestId(`voice-tile-${fixture.adminId}`)).toBeVisible();
|
||||
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
// Аудио реально приходит: ждём ненулевые байты входящего аудио-потока.
|
||||
for (const session of [adminSession, guestSession]) {
|
||||
await expect
|
||||
.poll(async () => inboundAudioBytes(session.page), { timeout: 60_000, intervals: [1000] })
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Серверный мьют гостя применяется на стороне SFU.
|
||||
const mute = await api.patch(
|
||||
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
|
||||
{
|
||||
data: { server_mute: true },
|
||||
},
|
||||
);
|
||||
expect(mute.ok(), `серверный мьют: ${await mute.text()}`).toBeTruthy();
|
||||
// Кнопка микрофона у гостя блокируется серверным мьютом.
|
||||
const guestMic = guestSession.page
|
||||
.getByRole('group', { name: /Управление голосом/u })
|
||||
.getByRole('button')
|
||||
.first();
|
||||
await expect(guestMic).toBeDisabled({ timeout: 30_000 });
|
||||
|
||||
// Отключение участника от голосовой комнаты убирает его из списка.
|
||||
const drop = await api.delete(
|
||||
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
|
||||
);
|
||||
expect(drop.ok(), `отключение от голосовой: ${await drop.text()}`).toBeTruthy();
|
||||
await expect(guestSession.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(adminSession.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
// Уборка: тестовый сервер удаляется вместе с комнатами и состояниями.
|
||||
await api.delete(`/api/v1/guilds/${fixture.guildId}`);
|
||||
await admin?.context.close();
|
||||
await guest?.context.close();
|
||||
await api.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['tests/**/*.{ts,tsx}'],
|
||||
files: ['tests/**/*.{ts,tsx}', 'e2e/**/*.ts', 'playwright.config.ts'],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+46
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
@@ -847,6 +848,22 @@
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/route-pattern": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz",
|
||||
@@ -3573,6 +3590,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright-конфигурация glchat: e2e-тесты идут против уже развёрнутого
|
||||
* инстанса (адрес в GLCHAT_URL, по умолчанию стенд), а не поднимают сервер —
|
||||
* так проверяется реальный путь через прокси, TLS и TURN (AGENT.md 11.2).
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 180_000,
|
||||
expect: { timeout: 20_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: process.env.GLCHAT_URL ?? 'https://gl.mhspx.su',
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'retain-on-failure',
|
||||
launchOptions: {
|
||||
// Фейковые медиа-устройства: Chromium отдаёт синтетический звук, поэтому
|
||||
// проверяется реальная доставка аудио через LiveKit (AGENT.md 11.2).
|
||||
args: [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
'--no-sandbox',
|
||||
],
|
||||
},
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.e2e.tsbuildinfo"
|
||||
},
|
||||
"include": ["playwright.config.ts", "e2e/**/*.ts"]
|
||||
}
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.e2e.json" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user