ee334a77bc
Пункты 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.
403 lines
19 KiB
TypeScript
403 lines
19 KiB
TypeScript
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||
|
||
import {
|
||
apiAs,
|
||
assignRole,
|
||
cleanupGuild,
|
||
createChannel,
|
||
createRole,
|
||
displayName,
|
||
dropStaleGuilds,
|
||
ensureProbeWithWait,
|
||
freshState,
|
||
joinVoiceOrSkip,
|
||
openChannel,
|
||
openGuild,
|
||
openWorkspace,
|
||
patchChannel,
|
||
postMessage,
|
||
prepareGuild,
|
||
repeat429,
|
||
sessionFrom,
|
||
stepUp,
|
||
trackLoads,
|
||
uploadSound,
|
||
type Account,
|
||
type GuildFixture,
|
||
type ProbeLogin,
|
||
type Session,
|
||
} from './support';
|
||
|
||
/**
|
||
* UI шапки сервера и саундборда — то, что в матрице 11.6 проверялось только по
|
||
* REST: кнопки ⚙/🔗/+ по правам, статус комнаты в шапке, счётчик непрочитанных
|
||
* и панель саундборда с локальным воспроизведением звука (AGENT.md 7.13, 7.15).
|
||
* Запуск только по флагу, тест требует стенда:
|
||
*
|
||
* GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \
|
||
* PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \
|
||
* npx playwright test e2e/guild-ui.spec.ts
|
||
*/
|
||
const enabled = process.env.GLCHAT_E2E_REALTIME === '1';
|
||
const API = '/api/v1';
|
||
/** Префикс имени тестового сервера: по нему убираются серверы прошлых прогонов. */
|
||
const GUILD_PREFIX = 'UI E2E ';
|
||
/** Постоянные пробные аккаунты: A — владелец, B — обычный участник. */
|
||
const OWNER_LOGIN: ProbeLogin = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' };
|
||
const MEMBER_LOGIN: ProbeLogin = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' };
|
||
/** Права роли, после выдачи которой кнопки шапки появляются у участника. */
|
||
const MANAGER_PERMISSIONS =
|
||
'VIEW_GUILD|VIEW_CHANNEL|SEND_MESSAGES|MANAGE_GUILD|MANAGE_CHANNELS|CREATE_INVITES|MANAGE_WEBHOOKS';
|
||
|
||
interface UiFixture extends GuildFixture {
|
||
secondChannelId: string;
|
||
secondChannelName: string;
|
||
voiceChannelId: string;
|
||
voiceChannelName: string;
|
||
}
|
||
|
||
let owner: Account;
|
||
let member: Account;
|
||
|
||
test.describe('UI шапки сервера, комнаты и саундборда', () => {
|
||
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_REALTIME=1');
|
||
|
||
// Лимит API — 120 запросов в минуту на сессию (AGENT.md 8.6): пауза между
|
||
// тестами даёт ведру лимита наполниться, иначе интерфейс получает 429.
|
||
test.afterEach(async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, 4_000));
|
||
});
|
||
|
||
test.beforeAll(async ({ playwright }) => {
|
||
const ownerApi = await playwright.request.newContext({ baseURL: standBase() });
|
||
const memberApi = await playwright.request.newContext({ baseURL: standBase() });
|
||
try {
|
||
// Лимит входов — 5 в минуту на IP (AGENT.md 8.6): входы разносим по времени.
|
||
const ownerProbe = await ensureProbeWithWait(ownerApi, OWNER_LOGIN);
|
||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||
const memberProbe = await ensureProbeWithWait(memberApi, MEMBER_LOGIN);
|
||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||
const ownerName = await displayName(ownerApi);
|
||
const memberName = await displayName(memberApi);
|
||
await stepUp(ownerApi, OWNER_LOGIN.password);
|
||
await dropStaleGuilds(ownerApi, GUILD_PREFIX);
|
||
await stepUp(memberApi, MEMBER_LOGIN.password);
|
||
await dropStaleGuilds(memberApi, GUILD_PREFIX);
|
||
owner = {
|
||
...ownerProbe,
|
||
displayName: ownerName,
|
||
state: await ownerApi.storageState(),
|
||
browserState: await freshState(ownerApi, OWNER_LOGIN),
|
||
};
|
||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||
member = {
|
||
...memberProbe,
|
||
displayName: memberName,
|
||
state: await memberApi.storageState(),
|
||
browserState: await freshState(memberApi, MEMBER_LOGIN),
|
||
};
|
||
} finally {
|
||
await ownerApi.dispose();
|
||
await memberApi.dispose();
|
||
}
|
||
});
|
||
|
||
test('25. Шапка сервера: 🔗/⚙/+ видны владельцу и появляются у участника по правам', async ({
|
||
browser,
|
||
playwright,
|
||
}) => {
|
||
const ownerApi = await apiAs(playwright, owner.state);
|
||
const memberApi = await apiAs(playwright, member.state);
|
||
const sessions: Session[] = [];
|
||
let fixture: UiFixture | null = null;
|
||
|
||
try {
|
||
fixture = await prepareUiGuild(ownerApi, memberApi);
|
||
// Владелец: в шапке сервера есть приглашение, настройки и создание комнаты.
|
||
const ownerSession = await sessionFrom(browser, owner.browserState);
|
||
sessions.push(ownerSession);
|
||
await openWorkspace(ownerSession.page, fixture);
|
||
await expect(ownerSession.page.getByTestId('guild-invite')).toBeVisible({ timeout: 20_000 });
|
||
await expect(ownerSession.page.getByLabel('Настройки сервера')).toBeVisible();
|
||
await expect(ownerSession.page.getByLabel('Создать комнату')).toBeVisible();
|
||
// Шестерёнка комнаты (вебхуки и доступ) — тоже по правам.
|
||
await expect(ownerSession.page.getByTestId('channel-settings')).toBeVisible();
|
||
|
||
// Обычный участник: кнопок управления нет, но комната и композер доступны.
|
||
const memberSession = await sessionFrom(browser, member.browserState);
|
||
sessions.push(memberSession);
|
||
await openWorkspace(memberSession.page, fixture);
|
||
const memberLoads = trackLoads(memberSession.page);
|
||
memberLoads.reset();
|
||
await expect(memberSession.page.getByTestId('channel-header')).toBeVisible();
|
||
await expect(memberSession.page.getByTestId('guild-invite')).toHaveCount(0);
|
||
await expect(memberSession.page.getByLabel('Настройки сервера')).toHaveCount(0);
|
||
await expect(memberSession.page.getByLabel('Создать комнату')).toHaveCount(0);
|
||
await expect(memberSession.page.getByTestId('channel-settings')).toHaveCount(0);
|
||
await expect(memberSession.page.getByTestId('composer')).toBeVisible();
|
||
|
||
// Выдача прав роли: кнопки появляются без перезагрузки (AGENT.md 11.6).
|
||
const role = await createRole(ownerApi, fixture.guildId, {
|
||
name: 'Управляющий',
|
||
permissions: MANAGER_PERMISSIONS,
|
||
});
|
||
await assignRole(ownerApi, fixture.guildId, member.id, role.id);
|
||
await expect(memberSession.page.getByTestId('guild-invite')).toBeVisible({ timeout: 20_000 });
|
||
await expect(memberSession.page.getByLabel('Настройки сервера')).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
await expect(memberSession.page.getByLabel('Создать комнату')).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
expect(memberLoads.count(), 'права применились без полной загрузки документа').toBe(0);
|
||
} finally {
|
||
for (const session of sessions) {
|
||
await session.context.close();
|
||
}
|
||
await cleanupGuild(ownerApi, fixture, OWNER_LOGIN.password);
|
||
await ownerApi.dispose();
|
||
await memberApi.dispose();
|
||
}
|
||
});
|
||
|
||
test('26. Шапка комнаты: статус и медленный режим без F5', async ({ browser, playwright }) => {
|
||
const ownerApi = await apiAs(playwright, owner.state);
|
||
const memberApi = await apiAs(playwright, member.state);
|
||
let session: Session | null = null;
|
||
let fixture: UiFixture | null = null;
|
||
|
||
try {
|
||
fixture = await prepareUiGuild(ownerApi, memberApi);
|
||
session = await sessionFrom(browser, member.browserState);
|
||
const page = session.page;
|
||
await openWorkspace(page, fixture);
|
||
const loads = trackLoads(page);
|
||
loads.reset();
|
||
|
||
// Статус комнаты: описание приходит событием и показывается в шапке.
|
||
const description = `Статус ${Date.now()}`;
|
||
await patchChannel(ownerApi, fixture.guildId, fixture.textChannelId, { description });
|
||
await expect(page.getByTestId('channel-description')).toHaveText(description, {
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// Медленный режим: в шапке появляется подпись с интервалом.
|
||
await patchChannel(ownerApi, fixture.guildId, fixture.textChannelId, {
|
||
slowmode_seconds: 5,
|
||
});
|
||
await expect(page.getByTestId('channel-header')).toContainText('медленный режим 5 с', {
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// Снятие ограничений убирает подписи: шапка следует за сервером.
|
||
await patchChannel(ownerApi, fixture.guildId, fixture.textChannelId, {
|
||
description: '',
|
||
slowmode_seconds: 0,
|
||
});
|
||
await expect(page.getByTestId('channel-description')).toHaveCount(0, { timeout: 20_000 });
|
||
expect(loads.count(), 'шапка обновлялась без полной загрузки документа').toBe(0);
|
||
} finally {
|
||
await session?.context.close();
|
||
await cleanupGuild(ownerApi, fixture, OWNER_LOGIN.password);
|
||
await ownerApi.dispose();
|
||
await memberApi.dispose();
|
||
}
|
||
});
|
||
|
||
test('27. Саундборд: панель открывается, звук играет локально', async ({
|
||
browser,
|
||
playwright,
|
||
}) => {
|
||
const ownerApi = await apiAs(playwright, owner.state);
|
||
const memberApi = await apiAs(playwright, member.state);
|
||
let session: Session | null = null;
|
||
let fixture: UiFixture | null = null;
|
||
|
||
try {
|
||
fixture = await prepareUiGuild(ownerApi, memberApi);
|
||
// Звук загружаем до входа в браузер: список приходит снапшотом сервера.
|
||
const soundId = await uploadSound(ownerApi, fixture.guildId, 'zvonok', 'soundboard');
|
||
|
||
const context = await browser.newContext({ storageState: owner.browserState });
|
||
// Считаем локальное воспроизведение: сервер только рассылает событие, а
|
||
// файл каждый клиент играет сам (`new Audio(...)` + `play()`, AGENT.md 7.13).
|
||
await context.addInitScript(() => {
|
||
const scope = window as unknown as { __soundCalls?: number; __soundPlayed?: number };
|
||
scope.__soundCalls = 0;
|
||
scope.__soundPlayed = 0;
|
||
const OriginalAudio = window.Audio;
|
||
window.Audio = function PatchedAudio(src?: string) {
|
||
const element = new OriginalAudio(src);
|
||
const original = element.play.bind(element);
|
||
element.play = () => {
|
||
scope.__soundCalls = (scope.__soundCalls ?? 0) + 1;
|
||
return original().then((value: void) => {
|
||
scope.__soundPlayed = (scope.__soundPlayed ?? 0) + 1;
|
||
return value;
|
||
});
|
||
};
|
||
return element;
|
||
} as unknown as typeof Audio;
|
||
});
|
||
const page = await context.newPage();
|
||
await page.goto('/app');
|
||
await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 });
|
||
session = { context, page };
|
||
|
||
// Входим в голосовую комнату один: панель саундборда живёт в плитке
|
||
// участника и без голосовой сессии сервер ответит `voice.not_connected`.
|
||
// При недоступном медиапути (ICE) проверка скипается с точной причиной,
|
||
// а дефект серверной части входа в голос валит тест (support.ts).
|
||
await openGuild(page, fixture.guildName);
|
||
await openChannel(page, fixture.voiceChannelName);
|
||
await joinVoiceOrSkip(page, ownerApi, fixture.voiceChannelId, fixture.voiceChannelName);
|
||
await expect(page.getByTestId('voice-soundboard-button')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByTestId('voice-soundboard-button').click();
|
||
const panel = page.getByTestId('voice-soundboard-panel');
|
||
await expect(panel).toBeVisible({ timeout: 20_000 });
|
||
const sound = panel.getByTestId(`voice-soundboard-sound-${soundId}`);
|
||
await expect(sound).toBeVisible({ timeout: 20_000 });
|
||
|
||
// Проигрывание: сервер отвечает 200, звук играет локально и всплывает подпись.
|
||
const played = page.waitForResponse(
|
||
(response) =>
|
||
response.request().method() === 'POST' &&
|
||
response.url().includes(`/sounds/${soundId}/play`),
|
||
{ timeout: 20_000 },
|
||
);
|
||
await sound.click();
|
||
expect((await played).status(), 'сервер принял проигрывание звука').toBe(200);
|
||
await expect(page.getByTestId('soundboard-notice')).toContainText('zvonok', {
|
||
timeout: 20_000,
|
||
});
|
||
const counters = await page.evaluate(
|
||
() => window as unknown as { __soundCalls?: number; __soundPlayed?: number },
|
||
);
|
||
expect(
|
||
counters.__soundCalls ?? 0,
|
||
'клиент запустил локальное воспроизведение',
|
||
).toBeGreaterThan(0);
|
||
expect(counters.__soundPlayed ?? 0, 'звук действительно заиграл локально').toBeGreaterThan(0);
|
||
} finally {
|
||
await session?.context.close();
|
||
await cleanupGuild(ownerApi, fixture, OWNER_LOGIN.password);
|
||
await ownerApi.dispose();
|
||
await memberApi.dispose();
|
||
}
|
||
});
|
||
|
||
test('28. Счётчик непрочитанных и отсутствие центра уведомлений', async ({
|
||
browser,
|
||
playwright,
|
||
}) => {
|
||
const ownerApi = await apiAs(playwright, owner.state);
|
||
const memberApi = await apiAs(playwright, member.state);
|
||
let session: Session | null = null;
|
||
let fixture: UiFixture | null = null;
|
||
|
||
try {
|
||
fixture = await prepareUiGuild(ownerApi, memberApi);
|
||
session = await sessionFrom(browser, member.browserState);
|
||
const page = session.page;
|
||
await openWorkspace(page, fixture);
|
||
// История должна быть загружена: отметка прочтения указывает на реальное
|
||
// сообщение, иначе счётчик непрочитанных посчитать нечем.
|
||
await expect(page.getByTestId('message-list').locator('article').first()).toBeVisible({
|
||
timeout: 30_000,
|
||
});
|
||
// Участник уходит во вторую комнату: первая становится непрочитанной.
|
||
// Отметку ставим ручкой сервера: в headless окно не в фокусе, и
|
||
// автоотметка клиента не срабатывает (так же делает матрица 11.6).
|
||
const lastId = newestMessageId(await channelMessages(memberApi, fixture.textChannelId));
|
||
const ack = await repeat429(() =>
|
||
memberApi.post(`${API}/channels/${fixture?.textChannelId ?? ''}/ack`, {
|
||
data: { last_message_id: lastId },
|
||
}),
|
||
);
|
||
expect(ack.ok(), `отметка прочтения: ${await ack.text()}`).toBeTruthy();
|
||
await openChannel(page, fixture.secondChannelName);
|
||
await expect(page.getByTestId('channel-header')).toContainText(fixture.secondChannelName);
|
||
const loads = trackLoads(page);
|
||
loads.reset();
|
||
|
||
await postMessage(ownerApi, fixture.textChannelId, `непрочитанное ${Date.now()}`);
|
||
const badge = page.getByTestId(`channel-unread-${fixture.textChannelId}`);
|
||
await expect(badge).toBeVisible({ timeout: 20_000 });
|
||
await expect(badge).toHaveText('1');
|
||
|
||
// Центра уведомлений в клиенте нет: «Уведомления» — только переключатель
|
||
// системных уведомлений в настройках (заглушка, отмечено в отчёте).
|
||
expect(
|
||
await page.getByRole('link', { name: /Уведомления/u }).count(),
|
||
'в оболочке нет ссылки на центр уведомлений',
|
||
).toBe(0);
|
||
expect(
|
||
await page.locator('[data-testid*="notification"]').count(),
|
||
'в DOM нет элементов центра уведомлений',
|
||
).toBe(0);
|
||
|
||
// Открытие комнаты гасит счётчик: отметка прочтения уходит на сервер.
|
||
await openChannel(page, fixture.textChannelName);
|
||
await expect(badge).toHaveCount(0, { timeout: 20_000 });
|
||
expect(loads.count(), 'счётчик менялся без полной загрузки документа').toBe(0);
|
||
} finally {
|
||
await session?.context.close();
|
||
await cleanupGuild(ownerApi, fixture, OWNER_LOGIN.password);
|
||
await ownerApi.dispose();
|
||
await memberApi.dispose();
|
||
}
|
||
});
|
||
});
|
||
|
||
/** standBase — адрес стенда: тот же, что у Playwright (GLCHAT_URL). */
|
||
function standBase(): string {
|
||
return process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
|
||
}
|
||
|
||
/** channelMessages читает историю комнаты: нужна отметка прочтения. */
|
||
async function channelMessages(api: APIRequestContext, channelId: string): Promise<unknown[]> {
|
||
const response = await repeat429(() => api.get(`${API}/channels/${channelId}/messages`));
|
||
if (!response.ok()) {
|
||
return [];
|
||
}
|
||
return ((await response.json()) as { messages: unknown[] }).messages;
|
||
}
|
||
|
||
/** newestMessageId выбирает самый свежий id из истории комнаты. */
|
||
function newestMessageId(messages: unknown[]): string {
|
||
let newest = '';
|
||
for (const item of messages) {
|
||
const id = (item as { id?: string }).id ?? '';
|
||
if (id === '') {
|
||
continue;
|
||
}
|
||
if (newest === '' || BigInt(id) > BigInt(newest)) {
|
||
newest = id;
|
||
}
|
||
}
|
||
return newest;
|
||
}
|
||
|
||
/** prepareUiGuild готовит сервер с двумя текстовыми и одной голосовой комнатой. */
|
||
async function prepareUiGuild(
|
||
ownerApi: APIRequestContext,
|
||
memberApi: APIRequestContext,
|
||
): Promise<UiFixture> {
|
||
const fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`);
|
||
const second = await createChannel(ownerApi, fixture.guildId, {
|
||
name: 'вторая',
|
||
type: 'text',
|
||
});
|
||
const voice = await createChannel(ownerApi, fixture.guildId, {
|
||
name: 'Голос',
|
||
type: 'voice',
|
||
});
|
||
return {
|
||
...fixture,
|
||
secondChannelId: second.id,
|
||
secondChannelName: second.name,
|
||
voiceChannelId: voice.id,
|
||
voiceChannelName: voice.name,
|
||
};
|
||
}
|