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:
@@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type APIResponse,
|
||||
type Browser,
|
||||
@@ -767,3 +768,134 @@ export async function openWorkspace(page: Page, fixture: GuildFixture): Promise<
|
||||
// Машина под нагрузкой грузит SPA медленнее: первой отрисовке даём запас.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user