From 5a419112e0043937dd607d12c680728a75a61524 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 11:06:51 +0300 Subject: [PATCH] =?UTF-8?q?test(web):=20e2e=20=D1=88=D0=B0=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B0=D1=83=D0=BD=D0=B4=D0=B1=D0=BE=D1=80=D0=B4?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверки, которые в матрице 11.6 обходились по REST, теперь идут через интерфейс (AGENT.md 7.13, 7.15, 11.6). - 25: 🔗/⚙/+ видны владельцу и не видны обычному участнику, а после выдачи роли с правами управления появляются без перезагрузки; - 26: статус комнаты и медленный режим появляются в шапке по событию и пропадают при снятии; - 27: саундборд — панель открывается в голосовой комнате, проигрывание уходит на сервер (200), звук играет локально (счётчик вызовов `Audio.play`, подпись «включил звук»). Если медиапуть LiveKit не поднялся, тест скипается с причиной: это внешняя зависимость; - 28: счётчик непрочитанных в сайдбаре и явная фиксация того, что центра уведомлений в клиенте нет (заглушка — только переключатель системных уведомлений в настройках). --- web/e2e/guild-ui.spec.ts | 440 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 web/e2e/guild-ui.spec.ts diff --git a/web/e2e/guild-ui.spec.ts b/web/e2e/guild-ui.spec.ts new file mode 100644 index 0000000..de814b6 --- /dev/null +++ b/web/e2e/guild-ui.spec.ts @@ -0,0 +1,440 @@ +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); + } + } +}