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 { 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, }; } /** * joinVoice входит в голосовую комнату: кнопка входа исчезает после * подключения. Подключение к SFU изредка срывается с первого раза, поэтому * попыток три — иначе тест падал бы на инфраструктуре, а не на матрице. */ async function joinVoice(page: Page, channelName: string): Promise { 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); } } }