Files
glchat/web/e2e/guild-ui.spec.ts
T

403 lines
19 KiB
TypeScript
Raw Normal View History

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,
};
}