test(web): голосовые e2e скипаются при недоступном медиапути

Пункты 3, 10 и 11 матрицы и тест саундборда падали не на продукте, а на
инфраструктуре: ICE через TURN на VPS не поднимается (STUN отвечает, но все
ICE-пары `failed` с `requestsReceived: 0`), и проверка трижды ждала кнопку
входа, после чего падал весь прогон.

В support.ts добавлен общий помощник joinVoiceIfPossible/joinVoiceOrSkip:
- три попытки, как раньше, но при уже показанной клиентом ошибке повтор не
  делается, а ожидание ограничено 25 секундами;
- при неудаче проверяется серверная часть входа (POST .../voice/join →
  токен LiveKit): живой сервер при мёртвом ICE — блокер среды, проверка
  скипается с точной причиной; нерабочая ручка — дефект продукта, тест
  падает. Проба ограничена по времени, а ответ 429 считается признаком
  живого сервера (лимит частоты к медиапути отношения не имеет);
- п. 11 при недоступном голосе не отменяется: голосовой мьют пропускается
  с аннотацией и предупреждением в лог, а тайм-аут, кик, возврат сервера в
  рейку и бан по-прежнему проверяются.

Три локальные копии joinVoice заменены одним помощником.

Прогоны на стенде (образ с HEAD): матрица 18 passed / 0 failed / 3 skipped,
§11.5 — 8 passed, два прогона подряд совпали; без правки было 3 failed.
This commit is contained in:
2026-09-26 14:15:53 +03:00
parent 73dd51b121
commit ee334a77bc
4 changed files with 172 additions and 109 deletions
+5 -43
View File
@@ -1,4 +1,4 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; import { expect, test, type APIRequestContext } from '@playwright/test';
import { import {
apiAs, apiAs,
@@ -10,6 +10,7 @@ import {
dropStaleGuilds, dropStaleGuilds,
ensureProbeWithWait, ensureProbeWithWait,
freshState, freshState,
joinVoiceOrSkip,
openChannel, openChannel,
openGuild, openGuild,
openWorkspace, openWorkspace,
@@ -245,22 +246,11 @@ test.describe('UI шапки сервера, комнаты и саундбор
// Входим в голосовую комнату один: панель саундборда живёт в плитке // Входим в голосовую комнату один: панель саундборда живёт в плитке
// участника и без голосовой сессии сервер ответит `voice.not_connected`. // участника и без голосовой сессии сервер ответит `voice.not_connected`.
// При недоступном медиапути (ICE) проверка скипается с точной причиной,
// а дефект серверной части входа в голос валит тест (support.ts).
await openGuild(page, fixture.guildName); await openGuild(page, fixture.guildName);
await openChannel(page, fixture.voiceChannelName); await openChannel(page, fixture.voiceChannelName);
let joined = true; await joinVoiceOrSkip(page, ownerApi, fixture.voiceChannelId, fixture.voiceChannelName);
try {
await joinVoice(page, fixture.voiceChannelName);
} catch {
joined = false;
}
if (!joined) {
// Медиапуть LiveKit — внешняя зависимость: если ICE не поднялся (сеть
// стенда или клиента), панель саундборда проверить нечем.
test.skip(
true,
'голос недоступен: LiveKit не поднял медиапуть (ICE failed) — панель саундборда не проверить',
);
}
await expect(page.getByTestId('voice-soundboard-button')).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId('voice-soundboard-button')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('voice-soundboard-button').click(); await page.getByTestId('voice-soundboard-button').click();
const panel = page.getByTestId('voice-soundboard-panel'); const panel = page.getByTestId('voice-soundboard-panel');
@@ -410,31 +400,3 @@ async function prepareUiGuild(
voiceChannelName: voice.name, voiceChannelName: voice.name,
}; };
} }
/**
* joinVoice входит в голосовую комнату: кнопка входа исчезает после
* подключения. Подключение к SFU изредка срывается с первого раза, поэтому
* попыток три — иначе тест падал бы на инфраструктуре, а не на матрице.
*/
async function joinVoice(page: Page, channelName: string): Promise<void> {
if (!page.url().includes(channelName)) {
await page
.getByRole('link', { name: `Открыть комнату ${channelName}`, exact: true })
.click({ timeout: 20_000 })
.catch(() => undefined);
}
const button = page.getByTestId('voice-join');
await button.waitFor({ state: 'visible', timeout: 20_000 });
for (let attempt = 1; attempt <= 3; attempt += 1) {
await button.click({ force: true, timeout: 10_000 }).catch(() => undefined);
try {
await expect(button).toBeHidden({ timeout: 40_000 });
return;
} catch (error) {
if (attempt === 3) {
throw error;
}
await page.waitForTimeout(2_000);
}
}
}
+23 -29
View File
@@ -12,6 +12,8 @@ import {
createGuild, createGuild,
dropStaleGuilds, dropStaleGuilds,
ensureProbe, ensureProbe,
joinVoiceIfPossible,
joinVoiceOrSkip,
openChannel, openChannel,
openGuild, openGuild,
postWithRetry, postWithRetry,
@@ -344,7 +346,9 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
}); });
// Голос: B входит в первую комнату, владелец переносит его во вторую. // Голос: B входит в первую комнату, владелец переносит его во вторую.
await joinVoice(page, fixture.voiceChannelId, fixture.voiceChannelName); // Дальше всё построено на голосовой сессии, поэтому при недоступном
// медиапути проверка скипается с причиной (см. support.ts).
await joinVoiceOrSkip(page, memberApi, fixture.voiceChannelId, fixture.voiceChannelName);
// Вторая комната создана до входа B: CHANNEL_CREATE приходит с // Вторая комната создана до входа B: CHANNEL_CREATE приходит с
// can_view:false, и созданная на лету комната в сайдбаре не появится. // can_view:false, и созданная на лету комната в сайдбаре не появится.
const moved = await repeat429(() => const moved = await repeat429(() =>
@@ -427,8 +431,20 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
.locator('li', { hasText: '@rtprobe2' }); .locator('li', { hasText: '@rtprobe2' });
await expect(memberRow).toBeVisible({ timeout: 20_000 }); await expect(memberRow).toBeVisible({ timeout: 20_000 });
// Серверный мьют отключает микрофон B. // Серверный мьют отключает микрофон B — но проверить его можно только
await joinVoice(memberSession.page, fixture.voiceChannelId, fixture.voiceChannelName); // внутри голосовой комнаты. Медиапуть LiveKit — внешняя зависимость:
// когда ICE не поднимается, мьют в голосовой проверить нечем, зато
// остальная модерация ниже (тайм-аут, кик, возврат в рейку, бан) обязана
// выполняться, поэтому голос здесь необязателен, а прогон получает
// аннотацию о сокращённом покрытии. Дефект серверной части входа в голос
// по-прежнему валит тест (support.ts).
const voiceJoin = await joinVoiceIfPossible(
memberSession.page,
memberApi,
fixture.voiceChannelId,
fixture.voiceChannelName,
);
if (voiceJoin.ok) {
const mute = await repeat429(() => const mute = await repeat429(() =>
ownerApi.patch(`${API}/guilds/${fixture.guildId}/voice-states/${member.id}`, { ownerApi.patch(`${API}/guilds/${fixture.guildId}/voice-states/${member.id}`, {
data: { server_mute: true }, data: { server_mute: true },
@@ -440,6 +456,10 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
.getByRole('button') .getByRole('button')
.first(); .first();
await expect(mic).toBeDisabled({ timeout: 30_000 }); await expect(mic).toBeDisabled({ timeout: 30_000 });
} else {
test.info().annotations.push({ type: 'voice-unavailable', description: voiceJoin.reason });
console.warn(`[e2e] пункт 11: голосовой мьют не проверен — ${voiceJoin.reason}`);
}
// Тайм-аут: писать участник больше не может — сервер отвечает отказом. // Тайм-аут: писать участник больше не может — сервер отвечает отказом.
const until = new Date(Date.now() + 10 * 60_000).toISOString(); const until = new Date(Date.now() + 10 * 60_000).toISOString();
@@ -890,32 +910,6 @@ async function openServerSettings(page: Page, guildId: string): Promise<void> {
await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 30_000 });
} }
/**
* joinVoice входит в голосовую комнату: кнопка входа исчезает после
* подключения. Подключение к SFU изредка срывается с первого раза, поэтому
* попыток три — иначе тест падал бы на инфраструктуре, а не на матрице.
*/
async function joinVoice(page: Page, channelId: string, channelName: string): Promise<void> {
if (!page.url().includes(channelId)) {
// Имя комнаты сравниваем точно: иначе «Голос» совпадёт с «Голос 2».
await page.getByRole('link', { name: `Открыть комнату ${channelName}`, exact: true }).click();
}
const button = page.getByTestId('voice-join');
await button.waitFor({ state: 'visible', timeout: 20_000 });
for (let attempt = 1; attempt <= 3; attempt += 1) {
await button.click({ force: true, timeout: 10_000 }).catch(() => undefined);
try {
await expect(button).toBeHidden({ timeout: 40_000 });
return;
} catch (error) {
if (attempt === 3) {
throw error;
}
await page.waitForTimeout(2_000);
}
}
}
/** changePassword меняет пароль пользователя через настройки безопасности. */ /** changePassword меняет пароль пользователя через настройки безопасности. */
async function changePassword(page: Page, current: string, next: string): Promise<void> { async function changePassword(page: Page, current: string, next: string): Promise<void> {
await page.goto('/settings/account/security'); await page.goto('/settings/account/security');
+2 -27
View File
@@ -13,6 +13,7 @@ import {
createInvite, createInvite,
dropStaleGuilds, dropStaleGuilds,
ensureProbe, ensureProbe,
joinVoiceOrSkip,
openChannel, openChannel,
openGuild, openGuild,
pngBytes, pngBytes,
@@ -359,7 +360,7 @@ test.describe('realtime: пункты 1–7 (сервер, роли, оформ
loads.reset(); loads.reset();
// Голосовая панель: B входит в комнату и виден под новым ником. // Голосовая панель: B входит в комнату и виден под новым ником.
await joinVoice(page, fixture.voiceChannelId, fixture.voiceChannelName); await joinVoiceOrSkip(page, memberApi, fixture.voiceChannelId, fixture.voiceChannelName);
await expect(page.getByRole('button', { name: `Участник ${nickname}` }).first()).toBeVisible({ await expect(page.getByRole('button', { name: `Участник ${nickname}` }).first()).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); });
@@ -934,32 +935,6 @@ async function sessionFrom(browser: Browser, state: StorageState): Promise<Sessi
return { context, page }; return { context, page };
} }
/**
* joinVoice входит в голосовую комнату: кнопка входа исчезает после
* подключения. Подключение к SFU изредка срывается с первого раза, поэтому
* попыток три — иначе тест падал бы на инфраструктуре, а не на матрице.
*/
async function joinVoice(page: Page, channelId: string, channelName: string): Promise<void> {
if (!page.url().includes(channelId)) {
// Имя комнаты сравниваем точно: иначе «Голос» совпадёт с «Голос 2».
await page.getByRole('link', { name: `Открыть комнату ${channelName}`, exact: true }).click();
}
const button = page.getByTestId('voice-join');
await button.waitFor({ state: 'visible', timeout: 20_000 });
for (let attempt = 1; attempt <= 3; attempt += 1) {
await button.click({ force: true, timeout: 10_000 }).catch(() => undefined);
try {
await expect(button).toBeHidden({ timeout: 40_000 });
return;
} catch (error) {
if (attempt === 3) {
throw error;
}
await page.waitForTimeout(2_000);
}
}
}
async function listChannels(api: APIRequestContext, guildId: string): Promise<Channel[]> { async function listChannels(api: APIRequestContext, guildId: string): Promise<Channel[]> {
const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/channels`)); const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/channels`));
expect( expect(
+132
View File
@@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto';
import { deflateSync } from 'node:zlib'; import { deflateSync } from 'node:zlib';
import { import {
expect, expect,
test,
type APIRequestContext, type APIRequestContext,
type APIResponse, type APIResponse,
type Browser, type Browser,
@@ -767,3 +768,134 @@ export async function openWorkspace(page: Page, fixture: GuildFixture): Promise<
// Машина под нагрузкой грузит SPA медленнее: первой отрисовке даём запас. // Машина под нагрузкой грузит SPA медленнее: первой отрисовке даём запас.
await expect(page.getByTestId('message-list')).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId('message-list')).toBeVisible({ timeout: 30_000 });
} }
/** Результат входа в голосовую комнату: подключились или почему нет. */
export interface VoiceJoinResult {
ok: boolean;
/** Причина для отчёта: что именно не поднялось. */
reason: string;
}
/**
* voiceJoinApiHealth проверяет серверную часть входа в голос: выдаёт ли инстанс
* токен LiveKit. Нужна, чтобы отличить сеть стенда (ICE не поднимается) от
* дефекта продукта (ручка входа не работает).
*
* Проба ограничена по времени и по числу попыток: у сессии теста лимит
* 120 запросов в минуту (AGENT.md 8.6), и «ждать окно» здесь нельзя — иначе
* проверка не успевает до таймаута теста. Ответ 429 считается признаком живого
* сервера: лимит частоты к медиапути отношения не имеет.
*/
async function voiceJoinApiHealth(
api: APIRequestContext,
channelId: string,
): Promise<VoiceJoinResult> {
let joined: APIResponse;
try {
joined = await repeat429(
() => api.post(`${API}/channels/${channelId}/voice/join`, { timeout: 15_000 }),
3,
);
} catch (error) {
return { ok: false, reason: `проба входа в голос не ответила: ${String(error)}` };
}
if (joined.status() === 429) {
return { ok: true, reason: 'ручка входа в голос жива (ответила лимитом частоты)' };
}
if (!joined.ok()) {
return {
ok: false,
reason: `POST /channels/${channelId}/voice/join → ${String(joined.status())}: ${await joined.text()}`,
};
}
const payload = (await joined.json()) as { token?: string; url?: string };
// Вход в голос записал состояние — выходим, чтобы не оставлять сессию висеть.
await repeat429(
() => api.post(`${API}/channels/${channelId}/voice/leave`, { timeout: 15_000 }),
2,
).catch(() => undefined);
if (payload.token === undefined || payload.token === '' || !payload.url) {
return { ok: false, reason: 'сервер вернул пустой токен или адрес LiveKit' };
}
return { ok: true, reason: 'серверная часть входа в голос работает' };
}
/**
* joinVoiceIfPossible входит в голосовую комнату так, как это делает
* пользователь: кнопка входа исчезает после подключения. Подключение к SFU
* изредка срывается с первого раза, поэтому попыток три — но если клиент уже
* показал ошибку, повторять нечего и мы выходим сразу.
*
* Медиапуть LiveKit — внешняя зависимость (ICE через TURN на VPS). Когда сеть
* его не пускает, а серверная часть входа жива, это блокер среды: тест обязан
* скипнуться с точной причиной, а не падать (PROGRESS.md, «Блокер среды:
* медиапуть LiveKit (ICE)»). Если недоступна и серверная часть — это дефект
* продукта, и помощник бросает исключение.
*/
export async function joinVoiceIfPossible(
page: Page,
api: APIRequestContext,
channelId: string,
channelName: string,
): Promise<VoiceJoinResult> {
if (!page.url().includes(channelId)) {
// Имя комнаты сравниваем точно: иначе «Голос» совпадёт с «Голос 2».
await page.getByRole('link', { name: `Открыть комнату ${channelName}`, exact: true }).click();
}
const button = page.getByTestId('voice-join');
await button.waitFor({ state: 'visible', timeout: 20_000 });
for (let attempt = 1; attempt <= 3; attempt += 1) {
await button.click({ force: true, timeout: 10_000 }).catch(() => undefined);
try {
// На живом медиапути подключение занимает секунды; 25 секунд — запас на
// медленную машину, и он же ограничивает ожидание при мёртвом ICE.
await expect(button).toBeHidden({ timeout: 25_000 });
return { ok: true, reason: '' };
} catch {
// Клиент показал ошибку подключения — ждать и повторять нечего.
const failed = await page
.getByRole('alert')
.first()
.isVisible()
.catch(() => false);
if (failed) {
break;
}
await page.waitForTimeout(2_000);
}
}
const health = await voiceJoinApiHealth(api, channelId);
// Читаем подпись с ограничением: без него locator ждёт появления элемента
// до таймаута теста, и проверка висит вместо скипа.
const alert = await page
.getByRole('alert')
.first()
.textContent({ timeout: 5_000 })
.catch(() => null);
const shown = alert === null || alert.trim() === '' ? '' : `, клиент показал «${alert.trim()}»`;
if (!health.ok) {
throw new Error(`голосовая комната недоступна: ${health.reason}${shown}`);
}
return {
ok: false,
reason: `медиапуть LiveKit не поднялся (ICE), ${health.reason}${shown}`,
};
}
/**
* joinVoiceOrSkip входит в голосовую комнату и скипает проверку с точной
* причиной, если подняться не удалось по вине среды (ICE), а серверная часть
* входа работает. Дефект продукта по-прежнему валит тест.
*/
export async function joinVoiceOrSkip(
page: Page,
api: APIRequestContext,
channelId: string,
channelName: string,
): Promise<void> {
const result = await joinVoiceIfPossible(page, api, channelId, channelName);
if (!result.ok) {
test.skip(true, result.reason);
}
}