Files
grendervill a835903938 fix(install,e2e): установка на VPS владельца — четыре дефекта шаблонов и хелперов
Установка на 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»).
2026-09-27 17:40:58 +03:00

260 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
});
});