a835903938
Установка на VPS (Debian 13, 1 vCPU/2 ГБ) с внешним прокси на том же хосте
вскрыла дефекты, которые не проявлялись на стенде:
- compose: с --no-turn порты TURN всё равно публиковались, и стек не стартовал
(«failed to bind host port 0.0.0.0:3478/udp» — порт держит чужой coturn).
Теперь строки портов TURN подставляет установщик (GLCHAT_TURN_PORTS).
- LiveKit: добавлен отдельный адрес публикации медиапортов
(--livekit-media-bind-addr / GLCHAT_LIVEKIT_MEDIA_BIND): сигналинг остаётся
внутренним для прокси, а 7881/tcp и 7882/udp доступны клиентам из интернета.
- external-proxy.conf: адреса апстримов берутся из фактической привязки
(--app-bind-addr/--livekit-bind-addr), а не жёсткого 127.0.0.1.
- при --no-files-subdomain шаблоны внешнего прокси и встроенного Caddy писали
два блока с одним адресом сайта (Caddy отвергает такой конфиг и в embedded
режиме установка ломалась целиком): блок пользовательского контента теперь
появляется только при отдельном домене (GLCHAT_FILES_BLOCK).
- e2e: postWithRetry/repeat429 читают retry_after_ms из конверта ошибки
({"error":{...}}) — без этого хелперы сдавались раньше, чем сервер разрешал
повтор, и регистрация пробных аккаунтов на свежем инстансе падала.
- e2e: voice.spec.ts ищет комнату «Голос» точным именем: пока сайдбар
дорисовывает сервер, видны комнаты предыдущего («Голос A»/«Голос B»).
260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
import {
|
||
expect,
|
||
test,
|
||
type APIRequestContext,
|
||
type Browser,
|
||
type BrowserContext,
|
||
type Page,
|
||
} from '@playwright/test';
|
||
|
||
import {
|
||
adminCredentials,
|
||
dropStaleGuilds,
|
||
ensureProbe,
|
||
finishOnboarding,
|
||
loginAsAdmin,
|
||
signIn,
|
||
} from './support';
|
||
|
||
/**
|
||
* Голосовой 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 PROBE_LOGIN = 'voiceprobe';
|
||
const admin = adminCredentials();
|
||
|
||
interface Fixture {
|
||
guildId: string;
|
||
guildName: string;
|
||
channelId: string;
|
||
guestEmail: string;
|
||
guestPassword: string;
|
||
adminId: string;
|
||
guestId: string;
|
||
}
|
||
|
||
/**
|
||
* prepare создаёт сервер и голосовую комнату, приглашает постоянного тестового
|
||
* пользователя. `admin` остаётся сессией администратора (модерация и уборка),
|
||
* `guest` — сессией приглашённого пользователя.
|
||
*/
|
||
async function prepare(adminApi: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
|
||
const adminUser = await loginAsAdmin(adminApi);
|
||
await finishOnboarding(adminApi);
|
||
await dropStaleGuilds(adminApi, 'Voice E2E ');
|
||
|
||
const stamp = Date.now();
|
||
const guildName = `Voice E2E ${stamp}`;
|
||
const guild = await adminApi.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 adminApi.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 adminApi.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 probe = await ensureProbe(guest, PROBE_LOGIN, 'Voice-Probe-Password-9x');
|
||
const accept = await guest.post(`/api/v1/invites/${code}`);
|
||
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
|
||
|
||
return {
|
||
guildId,
|
||
guildName,
|
||
channelId,
|
||
guestEmail: probe.email,
|
||
guestPassword: probe.password,
|
||
adminId: adminUser.id,
|
||
guestId: probe.id,
|
||
};
|
||
}
|
||
|
||
/** 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;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* signInVoice логинит браузерный контекст с фейковыми медиа-устройствами и
|
||
* патчем RTCPeerConnection: так проверяется реальная доставка аудио.
|
||
*/
|
||
async function signInVoice(
|
||
browser: Browser,
|
||
options: { email: string; password: string; totp?: string },
|
||
): Promise<{ context: BrowserContext; page: Page }> {
|
||
return signIn(browser, options, {
|
||
contextOptions: { permissions: ['microphone'] },
|
||
onContext: instrumentPeerConnections,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* joinVoice нажимает «Войти» в голосовой комнате. Кнопка исчезает в тот же
|
||
* момент, когда приходит состояние «Подключено», поэтому обычный click может
|
||
* зависнуть на проверке стабильности элемента: жмём без проверок и дожидаемся
|
||
* фактического результата — кнопки входа больше нет.
|
||
*/
|
||
async function joinVoice(page: Page): Promise<void> {
|
||
const button = page.getByTestId('voice-join');
|
||
await button.waitFor({ state: 'visible', timeout: 20_000 });
|
||
await button.click({ force: true, timeout: 10_000 }).catch(() => undefined);
|
||
await expect(button).toBeHidden({ timeout: 30_000 });
|
||
}
|
||
|
||
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 baseURL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
|
||
const adminApi = await playwright.request.newContext({ baseURL });
|
||
const guestApi = await playwright.request.newContext({ baseURL });
|
||
const fixture = await prepare(adminApi, guestApi);
|
||
let adminBrowser: Awaited<ReturnType<typeof signIn>> | null = null;
|
||
let guestBrowser: Awaited<ReturnType<typeof signIn>> | null = null;
|
||
|
||
try {
|
||
adminBrowser = await signInVoice(browser, {
|
||
email: admin.email,
|
||
password: admin.password,
|
||
totp: admin.totp,
|
||
});
|
||
guestBrowser = await signInVoice(browser, {
|
||
email: fixture.guestEmail,
|
||
password: fixture.guestPassword,
|
||
});
|
||
const adminSession = adminBrowser;
|
||
const guestSession = guestBrowser;
|
||
|
||
// Оба клиента открывают голосовую комнату и подключаются.
|
||
for (const session of [adminSession, guestSession]) {
|
||
await session.page
|
||
.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true })
|
||
.click();
|
||
// Имя комнаты — точно: пока сайдбар дорисовывает новый сервер, в нём
|
||
// могут быть видны комнаты предыдущего (например, «Голос A»/«Голос B»
|
||
// служебных проверок), и нечёткий локатор ловил бы две ссылки.
|
||
await session.page
|
||
.getByRole('link', { name: 'Открыть комнату Голос', exact: true })
|
||
.click();
|
||
await joinVoice(session.page);
|
||
}
|
||
|
||
// Каждый видит обоих участников.
|
||
for (const session of [adminSession, guestSession]) {
|
||
await expect(session.page.getByTestId(`voice-tile-${fixture.adminId}`)).toBeVisible();
|
||
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
|
||
}
|
||
|
||
// Пинг участника показывается при наведении на индикатор связи: своё
|
||
// значение измеряется по статистике WebRTC, чужое приходит по data-каналу.
|
||
for (const session of [adminSession, guestSession]) {
|
||
const ownQuality = session.page
|
||
.getByTestId(`voice-tile-${session === adminSession ? fixture.adminId : fixture.guestId}`)
|
||
.getByTestId('voice-quality');
|
||
await expect
|
||
.poll(async () => (await ownQuality.getAttribute('title')) ?? '', { timeout: 30_000 })
|
||
.toMatch(/\d+ мс/u);
|
||
}
|
||
// Каждый видит пинг другого участника.
|
||
await expect
|
||
.poll(
|
||
async () =>
|
||
(await adminSession.page
|
||
.getByTestId(`voice-tile-${fixture.guestId}`)
|
||
.getByTestId('voice-quality')
|
||
.getAttribute('title')) ?? '',
|
||
{ timeout: 30_000 },
|
||
)
|
||
.toMatch(/\d+ мс/u);
|
||
|
||
// Аудио реально приходит: ждём ненулевые байты входящего аудио-потока.
|
||
for (const session of [adminSession, guestSession]) {
|
||
await expect
|
||
.poll(async () => inboundAudioBytes(session.page), { timeout: 60_000, intervals: [1000] })
|
||
.toBeGreaterThan(0);
|
||
}
|
||
|
||
// Серверный мьют гостя применяется на стороне SFU.
|
||
const mute = await adminApi.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 adminApi.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 adminApi.delete(`/api/v1/guilds/${fixture.guildId}`);
|
||
await adminBrowser?.context.close();
|
||
await guestBrowser?.context.close();
|
||
await adminApi.dispose();
|
||
await guestApi.dispose();
|
||
}
|
||
});
|
||
});
|