Files
glchat/web/e2e/guild-ui.spec.ts
T
grendervill 5a419112e0 test(web): e2e шапки сервера, прав кнопок и саундборда
Проверки, которые в матрице 11.6 обходились по REST, теперь идут через
интерфейс (AGENT.md 7.13, 7.15, 11.6).

- 25: 🔗/⚙/+ видны владельцу и не видны обычному участнику, а после выдачи
  роли с правами управления появляются без перезагрузки;
- 26: статус комнаты и медленный режим появляются в шапке по событию и
  пропадают при снятии;
- 27: саундборд — панель открывается в голосовой комнате, проигрывание
  уходит на сервер (200), звук играет локально (счётчик вызовов
  `Audio.play`, подпись «включил звук»). Если медиапуть LiveKit не
  поднялся, тест скипается с причиной: это внешняя зависимость;
- 28: счётчик непрочитанных в сайдбаре и явная фиксация того, что центра
  уведомлений в клиенте нет (заглушка — только переключатель системных
  уведомлений в настройках).
2026-09-26 11:06:51 +03:00

441 lines
20 KiB
TypeScript
Raw 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 Page } from '@playwright/test';
import {
apiAs,
assignRole,
cleanupGuild,
createChannel,
createRole,
displayName,
dropStaleGuilds,
ensureProbeWithWait,
freshState,
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`.
await openGuild(page, fixture.guildName);
await openChannel(page, fixture.voiceChannelName);
let joined = true;
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 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,
};
}
/**
* 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);
}
}
}