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, }); // Локальное воспроизведение асинхронно: клиент сначала скачивает файл // (`/files/{id}` → blob), и только потом создаёт `Audio` и зовёт `play()`. // Подпись появляется раньше звука, поэтому счётчики не читаем сразу — // дожидаемся факта запуска (иначе проверка ловит гонку, а не дефект). const counter = (name: '__soundCalls' | '__soundPlayed'): Promise => page.evaluate( (key) => (window as unknown as Record)[key] ?? 0, name, ); await expect .poll(() => counter('__soundCalls'), { message: 'клиент запустил локальное воспроизведение', timeout: 20_000, }) .toBeGreaterThan(0); await expect .poll(() => counter('__soundPlayed'), { message: 'звук действительно заиграл локально', timeout: 20_000, }) .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 { 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 { 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, }; }