import { expect, test, type APIRequestContext, type APIResponse, type Browser, type BrowserContext, type Page, } from '@playwright/test'; import { createGuild, createInvite, dropStaleGuilds, ensureProbe, openChannel, openGuild, pngBytes, trackLoads, type Probe, } from './support'; /** * Realtime-матрица AGENT.md 11.6, пункты 1–7 (роли и права, профиль, * оформление сервера, эмодзи/звуки/косметика, инвайты, комнаты): владелец A * действует по REST, браузер B видит результат через Gateway — без F5 и * повторного входа. Запуск только по флагу, тест требует стенда: * * GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \ * PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \ * npx playwright test e2e/realtime.spec.ts */ const enabled = process.env.GLCHAT_E2E_REALTIME === '1'; const BASE_URL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; const API = '/api/v1'; /** Префикс имени тестового сервера: по нему убираются серверы прошлых прогонов. */ const GUILD_PREFIX = 'Realtime E2E '; /** Постоянные пробные аккаунты: A — владелец сервера, B — второй браузер. */ const OWNER_LOGIN = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' }; const MEMBER_LOGIN = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' }; /** * Права роли наблюдателя B: видит роли и участников сервера, но НЕ получает * ADMINISTRATOR — иначе выдача прав не была бы видна как новое действие. */ const MODERATOR_PERMISSIONS = 'VIEW_GUILD|VIEW_CHANNEL|READ_MESSAGE_HISTORY|SEND_MESSAGES|MANAGE_ROLES'; type StorageState = Awaited>; /** Открытая браузерная сессия: контекст и страница приложения. */ interface Session { context: BrowserContext; page: Page; } /** Фабрика REST-контекстов: сюда приходит фикстура `playwright` из теста. */ interface Requester { request: { newContext(options: { baseURL: string; storageState: StorageState; }): Promise; }; } /** Аккаунт стенда: профиль пробного пользователя и сохранённая сессия REST. */ interface Account extends Probe { /** Отображаемое имя: именно его показывает лента и панель участников. */ displayName: string; /** Сессия для REST-действий теста. */ state: StorageState; /** * Отдельная сессия для браузера: лимит API — 120 запросов в минуту на * сессию (AGENT.md 8.6), и общая сессия с тестом тормозила бы сам интерфейс. */ browserState: StorageState; } interface Role { id: string; name: string; color: number; position: number; permissions: string; is_default: boolean; } interface Channel { id: string; name: string; type: string; description: string; } interface GuildFixture { guildId: string; guildName: string; /** Текстовая комната, созданная вместе с сервером («общий»). */ textChannelId: string; textChannelName: string; voiceChannelId: string; voiceChannelName: string; /** Роль «Администратор», выданная владельцу при создании сервера. */ adminRoleId: string; /** Роль по умолчанию (@everyone). */ everyoneRoleId: string; } let owner: Account; let member: Account; test.describe('realtime: пункты 1–7 (сервер, роли, оформление)', () => { test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_REALTIME=1'); /** * Постоянные аккаунты входят один раз на файл: состояние сессий * переиспользуется тестами, поэтому прогон не упирается в лимит входов * (5 в минуту на IP), а тесты остаются независимыми. */ // Лимит 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: BASE_URL }); const memberApi = await playwright.request.newContext({ baseURL: BASE_URL }); 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); // Уборка серверов прошлых прогонов: их удаление требует свежий step-up. 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), }; member = { ...memberProbe, displayName: memberName, state: await memberApi.storageState(), browserState: await freshState(memberApi, MEMBER_LOGIN), }; } finally { await ownerApi.dispose(); await memberApi.dispose(); } }); test('1. Роль: создание, переименование, цвет, позиция и права сразу видны участнику', async ({ browser, playwright, }) => { // Раньше ROLE_CREATE/ROLE_UPDATE приходили без guild_id и клиент их // отбрасывал; теперь тело роли несёт guild_id, и список ролей у B // перечитывается по событию (AGENT.md 11.6). const ownerApi = await apiAs(playwright, owner.state); const memberApi = await apiAs(playwright, member.state); let session: Session | null = null; let fixture: GuildFixture | null = null; try { fixture = await prepareGuild(ownerApi, memberApi); // Роль модератора выдаётся до входа B: её права проверяются ниже. const moderator = await createRole(ownerApi, fixture.guildId, { name: 'Модератор', permissions: MODERATOR_PERMISSIONS, }); await assignRole(ownerApi, fixture.guildId, member.id, moderator.id); session = await openSession(browser, member); const page = session.page; await openGuild(page, fixture.guildName); // Настройки сервера открываются ссылкой: пункт «Сервер» в списке виден // только владельцу, а адрес открывает нужный раздел напрямую. await openServerSettings(page, fixture.guildId); const loads = trackLoads(page); loads.reset(); const roles = page.getByTestId('guild-roles'); await expect(roles.getByTestId(`role-${fixture.adminRoleId}`)).toBeVisible(); // Создание роли: у B появляется строка новой роли. const created = await createRole(ownerApi, fixture.guildId, { name: 'Тестовая', color: 0xff0000, permissions: 'VIEW_CHANNEL', }); const createdRow = roles.getByTestId(`role-${created.id}`); await expect(createdRow).toContainText('Тестовая'); // Переименование. await updateRole(ownerApi, fixture.guildId, created.id, { name: 'Тестовая роль' }); await expect(createdRow).toContainText('Тестовая роль'); // Цвет: кружок роли получает цвет новой роли. await updateRole(ownerApi, fixture.guildId, created.id, { color: 0x2ecc71 }); await expect(createdRow.locator('span[aria-hidden="true"]').first()).toHaveAttribute( 'style', /46,\s*204,\s*113/u, ); // Позиция: текст «Позиция N» и порядок списка (сортировка по убыванию). await updateRole(ownerApi, fixture.guildId, created.id, { position: 5 }); await expect(createdRow).toContainText('Позиция 5'); // Роль с позицией 5 встаёт сразу после «Администратора» (позиция 100), // выше остальных ролей с позицией 0. await expect .poll(async () => (await roleIds(page)).slice(0, 2), { timeout: 20_000 }) .toEqual([fixture.adminRoleId, created.id]); // Права: выдача прав роли B добавляет ему доступные действия — в // настройках появляется раздел «Эмодзи» (MANAGE_EMOJIS), которого до // выдачи не было, а в списке участников — меню модерации (KICK_MEMBERS). await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId('guild-emojis-counter')).toHaveCount(0); await expect(page.getByTestId(`member-moderate-${owner.id}`)).toHaveCount(0); await updateRole(ownerApi, fixture.guildId, moderator.id, { permissions: `${MODERATOR_PERMISSIONS}|MANAGE_EMOJIS|KICK_MEMBERS`, step_up_password: OWNER_LOGIN.password, }); await expect(page.getByTestId('guild-emojis-counter')).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId(`member-moderate-${owner.id}`)).toBeVisible({ timeout: 20_000, }); // Ни один шаг не потребовал перезагрузки страницы (пункт 14). expect(loads.count(), 'после подготовки не было полных загрузок документа').toBe(0); } finally { await session?.context.close(); await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); test('2. Выдача и снятие роли открывают и закрывают приватную комнату без перезахода', async ({ browser, playwright, }) => { // Выдача роли приходит событием MEMBER_UPDATE: клиент перечитывает // участников и список комнат, поэтому доступ к приватной комнате // появляется и пропадает без перезагрузки (AGENT.md 11.6). const ownerApi = await apiAs(playwright, owner.state); const memberApi = await apiAs(playwright, member.state); let session: Session | null = null; let fixture: GuildFixture | null = null; try { fixture = await prepareGuild(ownerApi, memberApi); const privateChannel = await createChannel(ownerApi, fixture.guildId, { name: 'приватная', type: 'text', }); // Приватность: @everyone теряет VIEW_CHANNEL на комнату (нужен step-up). const hidden = await repeat429(() => ownerApi.put( `${API}/guilds/${fixture.guildId}/channels/${privateChannel.id}/overwrites/role/${fixture.everyoneRoleId}`, { data: { deny: 'VIEW_CHANNEL', step_up_password: OWNER_LOGIN.password } }, ), ); expect(hidden.ok(), `приватная комната: ${await hidden.text()}`).toBeTruthy(); const viewerRole = await createRole(ownerApi, fixture.guildId, { name: 'Видит приватную', permissions: 'VIEW_CHANNEL', }); // Доступ выдаёт роль: у @everyone просмотр запрещён, у роли — разрешён. const allowed = await repeat429(() => ownerApi.put( `${API}/guilds/${fixture.guildId}/channels/${privateChannel.id}/overwrites/role/${viewerRole.id}`, { data: { allow: 'VIEW_CHANNEL', step_up_password: OWNER_LOGIN.password } }, ), ); expect(allowed.ok(), `доступ роли к комнате: ${await allowed.text()}`).toBeTruthy(); session = await openSession(browser, member); const page = session.page; await openWorkspace(page, fixture); const loads = trackLoads(page); loads.reset(); // Пока роли нет — комнаты нет ни в сайдбаре B, ни в ответе сервера. const link = page.getByRole('link', { name: /Открыть комнату приватная/u }); await expect(link).toHaveCount(0); expect(await visibleChannels(memberApi, fixture.guildId)).not.toContain(privateChannel.id); // Выдача роли: MEMBER_UPDATE должен перечитать видимые комнаты. await assignRole(ownerApi, fixture.guildId, member.id, viewerRole.id); await expect .poll(async () => visibleChannels(memberApi, fixture?.guildId ?? ''), { timeout: 20_000 }) .toContain(privateChannel.id); await expect(link).toBeVisible({ timeout: 20_000 }); // Снятие роли закрывает доступ так же мгновенно. await removeRole(ownerApi, fixture.guildId, member.id, viewerRole.id); await expect .poll(async () => visibleChannels(memberApi, fixture?.guildId ?? ''), { timeout: 20_000 }) .not.toContain(privateChannel.id); await expect(link).toHaveCount(0, { timeout: 20_000 }); expect(loads.count(), 'доступ менялся без полной загрузки документа').toBe(0); } finally { await session?.context.close(); await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); test('3. Ник участника сразу виден в чате, списке участников и голосовой панели', async ({ browser, playwright, }) => { const ownerApi = await apiAs(playwright, owner.state); const memberApi = await apiAs(playwright, member.state); let session: Session | null = null; let fixture: GuildFixture | null = null; try { fixture = await prepareGuild(ownerApi, memberApi); // Сообщение от B: по нему проверяется ник автора в ленте. await postMessage(memberApi, fixture.textChannelId, 'Сообщение для проверки ника'); session = await openSession(browser, member); const page = session.page; // Список участников: настройки сервера открываются ссылкой // `/settings/servers/…` (пункт «Сервер» виден только владельцу). await openServerSettings(page, fixture.guildId); const memberRow = page.getByTestId('guild-members').locator('li', { hasText: '@rtprobe2' }); await expect(memberRow).toContainText(member.displayName, { timeout: 30_000 }); // Владелец выдаёт B никнейм: MEMBER_UPDATE перечитывает участников, и // строка обновляется без перезагрузки. const nickname = `Ник-${Date.now() % 100_000}`; await renameMember(ownerApi, fixture.guildId, member.id, nickname); await expect(memberRow).toContainText(nickname, { timeout: 20_000 }); // Дальше всё живое: переходим в сервер и включаем счётчик загрузок. await page.keyboard.press('Escape'); await expect(page.getByTestId('settings-content')).toHaveCount(0); await openGuild(page, fixture.guildName); await openChannel(page, fixture.textChannelName); await expect(page.getByTestId('message-list').getByTestId('nick-name').first()).toHaveText( nickname, { timeout: 20_000 }, ); const loads = trackLoads(page); loads.reset(); // Голосовая панель: B входит в комнату и виден под новым ником. await joinVoice(page, fixture.voiceChannelId, fixture.voiceChannelName); await expect(page.getByRole('button', { name: `Участник ${nickname}` }).first()).toBeVisible({ timeout: 20_000, }); // Второй ник: панель участников голосовой комнаты и лента обновляются // на лету, без перезагрузки. const second = `${nickname}-2`; await renameMember(ownerApi, fixture.guildId, member.id, second); await expect(page.getByRole('button', { name: `Участник ${second}` }).first()).toBeVisible({ timeout: 20_000, }); await openChannel(page, fixture.textChannelName); await expect(page.getByTestId('message-list').getByTestId('nick-name').first()).toHaveText( second, { timeout: 20_000 }, ); expect(loads.count(), 'ник менялся без полной загрузки документа').toBe(0); } finally { await session?.context.close(); await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); test('4. Иконка, баннер, splash, имя, описание и публичность сервера обновляются у всех', async ({ browser, playwright, }) => { const ownerApi = await apiAs(playwright, owner.state); const memberApi = await apiAs(playwright, member.state); let session: Session | null = null; let invitePage: Page | null = null; let fixture: GuildFixture | null = null; try { // Владелец сервера — наблюдатель B: приглашение создаётся из его квоты. fixture = await prepareGuild(ownerApi, memberApi, { observerOwns: true }); const inviteCode = await createInvite(memberApi, fixture.guildId); session = await openSession(browser, member); const page = session.page; await openWorkspace(page, fixture); const loads = trackLoads(page); loads.reset(); // Иконка, баннер и splash грузятся как файлы сервера. const icon = await uploadGuildImage(ownerApi, fixture.guildId, 'icon', [30, 140, 220]); const banner = await uploadGuildImage(ownerApi, fixture.guildId, 'banner', [220, 60, 90]); const splash = await uploadGuildImage(ownerApi, fixture.guildId, 'splash', [40, 40, 40]); const renamed = `${GUILD_PREFIX}Обновлённый ${Date.now()}`; expect( await publicGuildNames(memberApi), 'до публикации сервера нет в каталоге', ).not.toContain(fixture.guildName); const patched = await repeat429(() => ownerApi.patch(`${API}/guilds/${fixture.guildId}`, { data: { name: renamed, description: 'Описание обновлено', public: true }, }), ); expect(patched.ok(), `правка сервера: ${await patched.text()}`).toBeTruthy(); // Шапка сервера у B: имя, баннер и иконка — через GUILD_UPDATE. await expect(page.getByTestId('guild-header').getByRole('heading')).toHaveText(renamed, { timeout: 20_000, }); await expect(page.getByTestId('guild-banner')).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId('guild-header')).toHaveAttribute( 'style', new RegExp(banner, 'u'), { timeout: 20_000 }, ); await expect( page.getByTestId('guild-rail').locator(`img[src*="${icon}"]`).first(), ).toBeVisible({ timeout: 20_000 }); expect(loads.count(), 'шапка и рейка обновились без полной загрузки документа').toBe(0); // Страница приглашения читает данные отдельным запросом и на Gateway не // подписана (GatewayBridge живёт только в AppLayout), поэтому её // актуальность проверяем на свежем документе — это не перезагрузка /app. invitePage = await session.context.newPage(); await invitePage.goto(`/invite/${inviteCode}`); await expect(invitePage.getByTestId('invite-guild-name')).toHaveText(renamed, { timeout: 20_000, }); await expect(invitePage.getByTestId('invite-background')).toBeVisible(); const splashImage = await invitePage .getByTestId('invite-background') .locator('xpath=preceding-sibling::img') .getAttribute('src'); expect(splashImage ?? '', 'на странице приглашения показан splash сервера').toContain(splash); // Публичность: сервер попал в каталог открытых серверов (экрана каталога // в web-клиенте нет, поэтому проверяется серверный список). await expect .poll(async () => publicGuildNames(memberApi), { timeout: 20_000 }) .toContain(renamed); } finally { await invitePage?.close(); await session?.context.close(); await cleanup(memberApi, fixture, MEMBER_LOGIN.password); await ownerApi.dispose(); await memberApi.dispose(); } }); test('5. Эмодзи, звуки и оформление роли сразу появляются у всех', async ({ browser, playwright, }) => { // Владелец сервера — наблюдатель B: только владельцу клиент показывает // разделы оформления и звуков (см. отчёт о пустом my_permissions в READY). const actorApi = await apiAs(playwright, owner.state); const observerApi = await apiAs(playwright, member.state); let session: Session | null = null; let fixture: GuildFixture | null = null; try { fixture = await prepareGuild(actorApi, observerApi, { observerOwns: true }); // Сообщение исполнителя: на нём проверяются пикер эмодзи и оформление. const actorMessage = await postMessage( actorApi, fixture.textChannelId, 'Сообщение владельца', ); session = await openSession(browser, member); const page = session.page; // Звуки: раздел «Звуки» в настройках сервера открыт у B, новые звуки // появляются в списке через GUILD_SOUNDS_UPDATE без перезагрузки. await openServerSettings(page, fixture.guildId); const loads = trackLoads(page); loads.reset(); const soundboardSound = await uploadSound( actorApi, fixture.guildId, `rt_sound_${Date.now() % 10_000}`, 'soundboard', ); await expect(page.getByTestId(`guild-sound-${soundboardSound}`)).toBeVisible({ timeout: 20_000, }); const paletteSound = await uploadSound( actorApi, fixture.guildId, `rt_ui_${Date.now() % 10_000}`, 'ui', ); await page.getByTestId('guild-sounds-tab-ui').click({ timeout: 15_000 }); await expect(page.getByTestId(`guild-sound-${paletteSound}`)).toBeVisible({ timeout: 20_000, }); // Эмодзи сервера: появляется в пикере у B через GUILD_EMOJIS_UPDATE. const emojiName = `rt_emoji_${Date.now() % 10_000}`; await uploadEmoji(actorApi, fixture.guildId, emojiName); await page.keyboard.press('Escape'); await expect(page.getByTestId('settings-content')).toHaveCount(0); await openGuild(page, fixture.guildName); await openChannel(page, fixture.textChannelName); const list = page.getByTestId('message-list'); const message = list.locator(`article[data-testid="message-${actorMessage}"]`); await expect(message).toBeVisible({ timeout: 20_000 }); await message.hover(); await message .getByRole('button', { name: 'Действия с сообщением' }) .click({ timeout: 15_000 }); await page.getByRole('menuitem', { name: 'Реакция' }).click({ timeout: 15_000 }); const picker = page.getByTestId('emoji-picker-server'); await expect(picker).toBeVisible({ timeout: 20_000 }); await expect(picker.getByRole('button', { name: `Выбрать эмодзи ${emojiName}` })).toBeVisible( { timeout: 20_000, }, ); await page.keyboard.press('Escape'); // Оформление роли: рамка и иконка из галереи выдаются роли исполнителя и // появляются у его сообщения в ленте B без перезагрузки. const badgeId = await uploadCosmetic(actorApi, fixture.guildId, 'badge', 'Иконка'); const frameId = await uploadCosmetic(actorApi, fixture.guildId, 'frame', 'Рамка'); const styled = await createRole(actorApi, fixture.guildId, { name: 'Оформленная', permissions: 'VIEW_CHANNEL', cosmetic_badge_id: badgeId, cosmetic_frame_id: frameId, }); // Оформление автора живёт в шапке группы сообщений: иконка и рамка // появляются у единственного оформленного участника без перезагрузки. // Выдачу повторяем, если клиент не перечитал участников: под нагрузкой // запрос списка участников может не пройти с первого раза. for (let attempt = 1; attempt <= 3; attempt += 1) { await assignRole(actorApi, fixture.guildId, owner.id, styled.id); try { await expect(list.getByTestId('nick-badge')).toBeVisible({ timeout: 15_000 }); break; } catch (error) { if (attempt === 3) { throw error; } } } await expect(list.getByTestId('avatar-frame')).toBeVisible({ timeout: 20_000 }); expect(loads.count(), 'оформление менялось без полной загрузки документа').toBe(0); } finally { await session?.context.close(); await cleanup(observerApi, fixture, MEMBER_LOGIN.password); await actorApi.dispose(); await observerApi.dispose(); } }); test('6. Инвайты: создание и отзыв ссылки сразу меняют список приглашений', async ({ browser, playwright, }) => { // Владелец сервера — наблюдатель B: панель приглашений и квота его. const guestApi = await apiAs(playwright, owner.state); const ownerApi = await apiAs(playwright, member.state); let session: Session | null = null; let fixture: GuildFixture | null = null; try { fixture = await prepareGuild(guestApi, ownerApi, { observerOwns: true }); session = await openSession(browser, member); const page = session.page; await openWorkspace(page, fixture); // Список приглашений сервера читается ручкой REST: экрана списка в // web-клиенте нет, панель показывает только созданную ссылку (см. отчёт). const listInvites = async (): Promise => { const response = await repeat429(() => ownerApi.get(`${API}/guilds/${fixture?.guildId}/invites`), ); expect(response.ok(), `список приглашений: ${await response.text()}`).toBeTruthy(); const payload = (await response.json()) as { invites: { code: string }[] }; return payload.invites.map((invite) => invite.code); }; expect(await listInvites(), 'до создания приглашений нет').toHaveLength(0); // Приглашение создаётся владельцем через панель сервера. await page.getByTestId('guild-invite').click(); const linkInput = page.getByTestId('guild-invite-link'); await expect(linkInput).toBeVisible({ timeout: 20_000 }); const code = inviteCodeFromUrl(await linkInput.inputValue()); await expect.poll(listInvites, { timeout: 20_000 }).toContain(code); // Отзыв: код пропадает из списка и больше не пускает новых участников. const revoked = await repeat429(() => ownerApi.delete(`${API}/invites/${code}`)); expect(revoked.ok(), `отзыв приглашения: ${await revoked.text()}`).toBeTruthy(); await expect.poll(listInvites, { timeout: 20_000 }).not.toContain(code); const left = await repeat429(() => guestApi.post(`${API}/guilds/${fixture.guildId}/leave`)); expect(left.ok(), `выход из сервера: ${await left.text()}`).toBeTruthy(); const join = await repeat429(() => guestApi.post(`${API}/invites/${code}`)); expect(join.status(), 'отозванное приглашение больше не работает').toBe(404); await expect(linkInput).toHaveValue(new RegExp(code, 'u')); } finally { await session?.context.close(); await cleanup(ownerApi, fixture, MEMBER_LOGIN.password); await ownerApi.dispose(); await guestApi.dispose(); } }); test('7. Каналы: создание, переименование, перемещение, удаление, фон и статус', async ({ browser, playwright, }) => { // Комнаты приходят событиями CHANNEL_CREATE/CHANNEL_UPDATE: клиент // перечитывает список комнат сервера, поэтому сайдбар обновляется без // перезагрузки (AGENT.md 11.6). const ownerApi = await apiAs(playwright, owner.state); const memberApi = await apiAs(playwright, member.state); let session: Session | null = null; let fixture: GuildFixture | null = null; try { fixture = await prepareGuild(ownerApi, memberApi); session = await openSession(browser, member); const page = session.page; await openWorkspace(page, fixture); const loads = trackLoads(page); loads.reset(); // Создание комнаты. const channel = await createChannel(ownerApi, fixture.guildId, { name: 'новая-комната', type: 'text', }); const link = page.getByRole('link', { name: /Открыть комнату новая-комната/u }); await expect(link).toBeVisible({ timeout: 20_000 }); // Переименование. await patchChannel(ownerApi, fixture.guildId, channel.id, { name: 'переименованная' }); const renamedLink = page.getByRole('link', { name: /Открыть комнату переименованная/u }); await expect(renamedLink).toBeVisible({ timeout: 20_000 }); // Перемещение: позиция задаёт порядок ссылок в сайдбаре (у «общий» и // «Голос» позиция 0, поэтому комната с позицией 1 идёт последней). await patchChannel(ownerApi, fixture.guildId, channel.id, { position: 1 }); await expect .poll(async () => sidebarChannelNames(page), { timeout: 20_000 }) .toEqual([fixture.textChannelName, fixture.voiceChannelName, 'переименованная']); // Фон комнаты: у B в открытой комнате появляется оверлей фона. await renamedLink.click(); await expect(page.getByTestId('channel-header')).toContainText('переименованная'); const background = await uploadChannelBackground(ownerApi, channel.id); expect(background, 'фон комнаты загружен').toBeTruthy(); await expect(page.getByTestId('channel-background-overlay')).toBeVisible({ timeout: 20_000, }); // Статус комнаты: у комнаты есть описание (в шапке клиент его не // показывает, поэтому проверяем ответ сервера). await patchChannel(ownerApi, fixture.guildId, channel.id, { description: 'Статус комнаты', }); await expect .poll(async () => channelDescription(ownerApi, fixture?.guildId ?? '', channel.id), { timeout: 20_000, }) .toBe('Статус комнаты'); // Удаление комнаты убирает ссылку у B. const removed = await repeat429(() => ownerApi.delete(`${API}/guilds/${fixture.guildId}/channels/${channel.id}`), ); expect(removed.ok(), `удаление комнаты: ${await removed.text()}`).toBeTruthy(); await expect(renamedLink).toHaveCount(0, { timeout: 20_000 }); expect(loads.count(), 'сайдбар обновлялся без полной загрузки документа').toBe(0); } finally { await session?.context.close(); await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); }); /** publicGuildNames читает каталог открытых серверов (AGENT.md 7.20). */ async function publicGuildNames(api: APIRequestContext): Promise { const response = await repeat429(() => api.get(`${API}/guilds/public`)); expect(response.ok(), `каталог серверов: ${await response.text()}`).toBeTruthy(); const payload = (await response.json()) as { guilds: { name: string }[] }; return payload.guilds.map((guild) => guild.name); } /** renameMember выдаёт участнику никнейм на сервере. */ async function renameMember( api: APIRequestContext, guildId: string, userId: string, nickname: string, ): Promise { const response = await repeat429(() => api.patch(`${API}/guilds/${guildId}/members/${userId}`, { data: { nickname }, }), ); expect(response.ok(), `смена ника: ${await response.text()}`).toBeTruthy(); } /** freshState входит заново и отдаёт состояние новой сессии аккаунта. */ async function freshState( api: APIRequestContext, login: { username: string; password: string }, ): Promise { const response = await repeat429(() => api.post(`${API}/auth/login`, { data: { email: `${login.username}@gl.mhspx.su`, password: login.password }, }), ); if (!response.ok()) { // Лимит входов (5 в минуту на IP) исчерпан: ждём окно и пробуем ещё раз, // иначе браузер и REST разделят одну сессию и упрутся в лимит запросов. await new Promise((resolve) => setTimeout(resolve, 65_000)); const retry = await repeat429(() => api.post(`${API}/auth/login`, { data: { email: `${login.username}@gl.mhspx.su`, password: login.password }, }), ); expect(retry.ok(), `повторный вход для браузера: ${await retry.text()}`).toBeTruthy(); } return api.storageState(); } /** displayName читает отображаемое имя аккаунта: его показывает интерфейс. */ async function displayName(api: APIRequestContext): Promise { const response = await repeat429(() => api.get(`${API}/users/@me`)); expect(response.ok(), `текущий пользователь: ${await response.text()}`).toBeTruthy(); const payload = (await response.json()) as { user: { display_name: string } }; return payload.user.display_name; } /** * repeat429 повторяет запрос при срабатывании лимита частоты: сервер отвечает * `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты, * которая как раз и обязана работать (AGENT.md 8.6). */ async function repeat429(request: () => Promise, attempts = 20): Promise { let response = await request(); for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number }; // Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту. const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 1_000), 30_000); await new Promise((resolve) => setTimeout(resolve, wait + 250)); response = await request(); } return response; } /** * ensureProbeWithWait входит в пробный аккаунт, пережидая лимит входов: сервер * пускает 5 попыток в минуту на IP (AGENT.md 8.6), поэтому при 429 ждём окно * целиком и повторяем, а не падаем всем файлом. */ async function ensureProbeWithWait( api: APIRequestContext, login: { username: string; password: string }, ): Promise { for (let attempt = 0; attempt < 3; attempt += 1) { try { return await ensureProbe(api, login.username, login.password); } catch { await new Promise((resolve) => setTimeout(resolve, 65_000)); } } throw new Error(`не удалось войти в пробный аккаунт ${login.username}`); } /** apiAs создаёт REST-контекст из сохранённой сессии: вход не тратится. */ function apiAs(playwright: Requester, state: StorageState): Promise { return playwright.request.newContext({ baseURL: BASE_URL, storageState: state }); } /** stepUp подтверждает личность: чувствительные ручки требуют свежий step-up. */ async function stepUp(api: APIRequestContext, password: string): Promise { const response = await repeat429(() => api.post(`${API}/auth/step-up`, { data: { password } })); expect(response.ok(), `step-up: ${await response.text()}`).toBeTruthy(); } /** * prepareGuild создаёт сервер от имени владельца, комнаты и приглашает * участника: дальше владелец действует по REST, участник смотрит в браузере. */ async function prepareGuild( actorApi: APIRequestContext, observerApi: APIRequestContext, options: { observerOwns?: boolean } = {}, ): Promise { // Сервер создаёт либо владелец-исполнитель A, либо наблюдатель B: второму // варианту нужны тесты, где браузер смотрит на панель владельца (инвайты). const ownerApi = options.observerOwns === true ? observerApi : actorApi; const joinerApi = options.observerOwns === true ? actorApi : observerApi; const guildName = `${GUILD_PREFIX}${Date.now()}`; const guild = await createGuild(ownerApi, guildName); const channels = await listChannels(ownerApi, guild.id); const text = channels.find((channel) => channel.type === 'text'); expect(text, 'у нового сервера есть текстовая комната').toBeTruthy(); const voice = await createChannel(ownerApi, guild.id, { name: 'Голос', type: 'voice' }); const roles = await listRoles(ownerApi, guild.id); const admin = roles.find( (role) => !role.is_default && role.permissions.includes('ADMINISTRATOR'), ); const everyone = roles.find((role) => role.is_default); expect(admin, 'владелец получает роль с ADMINISTRATOR').toBeTruthy(); expect(everyone, 'у сервера есть роль по умолчанию').toBeTruthy(); // Вступление без приглашения: сервер на время делается открытым, потому что // квота приглашений — 10 в сутки на пользователя (AGENT.md 8.6). const opened = await repeat429(() => ownerApi.patch(`${API}/guilds/${guild.id}`, { data: { public: true } }), ); expect(opened.ok(), `открытый сервер: ${await opened.text()}`).toBeTruthy(); const joined = await repeat429(() => joinerApi.post(`${API}/guilds/${guild.id}/join`)); expect(joined.ok(), `вступление в сервер: ${await joined.text()}`).toBeTruthy(); const closed = await repeat429(() => ownerApi.patch(`${API}/guilds/${guild.id}`, { data: { public: false } }), ); expect(closed.ok(), `закрытие сервера: ${await closed.text()}`).toBeTruthy(); if (options.observerOwns === true) { // Владелец выдаёт исполнителю права администратора сервера. await assignRole(ownerApi, guild.id, owner.id, admin?.id ?? ''); } return { guildId: guild.id, guildName, textChannelId: text?.id ?? '', textChannelName: text?.name ?? '', voiceChannelId: voice.id, voiceChannelName: voice.name, adminRoleId: admin?.id ?? '', everyoneRoleId: everyone?.id ?? '', }; } /** cleanup удаляет тестовый сервер: мусор на стенде не остаётся. */ async function cleanup( ownerApi: APIRequestContext, fixture: GuildFixture | null, password = OWNER_LOGIN.password, ): Promise { if (fixture === null) { return; } await stepUp(ownerApi, password).catch(() => undefined); await repeat429(() => ownerApi.delete(`${API}/guilds/${fixture.guildId}`)).catch(() => undefined); } /** openWorkspace открывает сервер и текстовую комнату у B. */ async function openWorkspace(page: Page, fixture: GuildFixture): Promise { await openGuild(page, fixture.guildName); await openChannel(page, fixture.textChannelName); await expect(page.getByTestId('guild-header')).toContainText(fixture.guildName, { timeout: 30_000, }); // Машина под нагрузкой грузит SPA медленнее: первой отрисовке даём запас. await expect(page.getByTestId('message-list')).toBeVisible({ timeout: 30_000 }); } /** * openServerSettings открывает настройки сервера ссылкой `/settings/servers/…`: * пункт «Сервер» в списке виден только владельцу, а адрес открывает раздел * напрямую — права при этом проверяет сам раздел по свежему ответу REST. */ async function openServerSettings(page: Page, guildId: string): Promise { await page.goto(`/settings/servers/${guildId}`); await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 30_000 }); } /** * openSession открывает браузерный контекст из подготовленной сессии: вход * выполняется один раз на файл, поэтому прогон не упирается в лимит входов * (5 в минуту на IP, AGENT.md 8.6). */ async function openSession(browser: Browser, account: Account): Promise { const context = await browser.newContext({ storageState: account.browserState }); const page = await context.newPage(); await page.goto('/app'); await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 }); return { context, page }; } /** * joinVoice входит в голосовую комнату: кнопка входа исчезает после * подключения. Подключение к SFU изредка срывается с первого раза, поэтому * попыток три — иначе тест падал бы на инфраструктуре, а не на матрице. */ async function joinVoice(page: Page, channelId: string, channelName: string): Promise { if (!page.url().includes(channelId)) { // Имя комнаты сравниваем точно: иначе «Голос» совпадёт с «Голос 2». await page.getByRole('link', { name: `Открыть комнату ${channelName}`, exact: true }).click(); } 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); } } } async function listChannels(api: APIRequestContext, guildId: string): Promise { const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/channels`)); expect( response.ok(), `комнаты сервера (${String(response.status())}): ${await response.text()}`, ).toBeTruthy(); return ((await response.json()) as { channels: Channel[] }).channels; } /** visibleChannels возвращает id комнат, которые сервер отдаёт пользователю. */ async function visibleChannels(api: APIRequestContext, guildId: string): Promise { const channels = await listChannels(api, guildId); return channels.map((channel) => channel.id); } async function listRoles(api: APIRequestContext, guildId: string): Promise { const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/roles`)); expect( response.ok(), `роли сервера (${String(response.status())}): ${await response.text()}`, ).toBeTruthy(); return ((await response.json()) as { roles: Role[] }).roles; } async function createRole( api: APIRequestContext, guildId: string, body: Record, ): Promise { const response = await repeat429(() => api.post(`${API}/guilds/${guildId}/roles`, { data: body }), ); expect(response.ok(), `создание роли: ${await response.text()}`).toBeTruthy(); return ((await response.json()) as { role: Role }).role; } async function updateRole( api: APIRequestContext, guildId: string, roleId: string, body: Record, ): Promise { const response = await repeat429(() => api.patch(`${API}/guilds/${guildId}/roles/${roleId}`, { data: body }), ); expect(response.ok(), `правка роли: ${await response.text()}`).toBeTruthy(); } async function assignRole( api: APIRequestContext, guildId: string, userId: string, roleId: string, ): Promise { const response = await repeat429(() => api.put(`${API}/guilds/${guildId}/members/${userId}/roles/${roleId}`), ); expect(response.ok(), `выдача роли: ${await response.text()}`).toBeTruthy(); } async function removeRole( api: APIRequestContext, guildId: string, userId: string, roleId: string, ): Promise { const response = await repeat429(() => api.delete(`${API}/guilds/${guildId}/members/${userId}/roles/${roleId}`), ); expect(response.ok(), `снятие роли: ${await response.text()}`).toBeTruthy(); } async function createChannel( api: APIRequestContext, guildId: string, body: Record, ): Promise { const response = await repeat429(() => api.post(`${API}/guilds/${guildId}/channels`, { data: body }), ); expect(response.ok(), `создание комнаты: ${await response.text()}`).toBeTruthy(); return ((await response.json()) as { channel: Channel }).channel; } async function patchChannel( api: APIRequestContext, guildId: string, channelId: string, body: Record, ): Promise { const response = await repeat429(() => api.patch(`${API}/guilds/${guildId}/channels/${channelId}`, { data: body, }), ); expect(response.ok(), `правка комнаты: ${await response.text()}`).toBeTruthy(); } /** postMessage отправляет сообщение по REST: так действует A по сценарию. */ async function postMessage( api: APIRequestContext, channelId: string, content: string, ): Promise { const response = await repeat429(() => api.post(`${API}/channels/${channelId}/messages`, { data: { content } }), ); expect(response.ok(), `отправка сообщения: ${await response.text()}`).toBeTruthy(); return ((await response.json()) as { message: { id: string } }).message.id; } /** channelDescription читает описание комнаты: в REST это «статус» комнаты. */ async function channelDescription( api: APIRequestContext, guildId: string, channelId: string, ): Promise { const channels = await listChannels(api, guildId); return channels.find((channel) => channel.id === channelId)?.description ?? ''; } /** roleIds возвращает порядок ролей в списке (сервер сортирует по позиции). */ async function roleIds(page: Page): Promise { return page .getByTestId('guild-roles') .locator('li') .evaluateAll((nodes) => nodes.map((node) => (node.getAttribute('data-testid') ?? '').replace('role-', '')), ); } /** sidebarChannelNames возвращает порядок комнат в сайдбаре. */ async function sidebarChannelNames(page: Page): Promise { return page .getByRole('navigation', { name: 'Комнаты' }) .getByRole('link') .evaluateAll((nodes) => nodes .map((node) => node.getAttribute('aria-label') ?? '') .filter((label) => label.startsWith('Открыть комнату ')) .map((label) => label.replace('Открыть комнату ', '')), ); } /** uploadGuildImage загружает иконку, баннер или splash сервера. */ async function uploadGuildImage( api: APIRequestContext, guildId: string, kind: 'icon' | 'banner' | 'splash', rgb: [number, number, number], ): Promise { const response = await repeat429(() => api.post(`${API}/guilds/${guildId}/appearance/${kind}`, { multipart: { file: { name: `${kind}.png`, mimeType: 'image/png', buffer: pngBytes(96, 96, rgb) }, }, }), ); expect(response.ok(), `оформление ${kind}: ${await response.text()}`).toBeTruthy(); const payload = (await response.json()) as { guild: Record }; return payload.guild[`${kind}_file_id`] ?? ''; } /** uploadEmoji загружает кастомный эмодзи сервера. */ async function uploadEmoji(api: APIRequestContext, guildId: string, name: string): Promise { const response = await repeat429(() => api.post(`${API}/guilds/${guildId}/emojis`, { multipart: { name, file: { name: 'emoji.png', mimeType: 'image/png', buffer: pngBytes(32, 32, [255, 180, 0]) }, }, }), ); expect(response.ok(), `загрузка эмодзи: ${await response.text()}`).toBeTruthy(); return ((await response.json()) as { emoji: { id: string } }).emoji.id; } /** uploadSound загружает звук саундборда или звуковой палитры сервера. */ async function uploadSound( api: APIRequestContext, guildId: string, name: string, kind: 'soundboard' | 'ui', ): Promise { const response = await repeat429(() => api.post(`${API}/guilds/${guildId}/sounds?kind=${kind}`, { multipart: { name, ...(kind === 'ui' ? { event: 'message' } : {}), file: { name: 'sound.wav', mimeType: 'audio/wav', buffer: wavBytes() }, }, }), ); expect(response.ok(), `загрузка звука: ${await response.text()}`).toBeTruthy(); return ((await response.json()) as { sound: { id: string } }).sound.id; } /** uploadCosmetic загружает элемент галереи оформления (рамку или иконку). */ async function uploadCosmetic( api: APIRequestContext, guildId: string, kind: 'frame' | 'badge', name: string, ): Promise { const response = await repeat429(() => api.post(`${API}/guilds/${guildId}/cosmetics`, { multipart: { kind, name, file: { name: `${kind}.png`, mimeType: 'image/png', buffer: pngBytes(48, 48, [10, 200, 90]), }, }, }), ); expect(response.ok(), `загрузка оформления: ${await response.text()}`).toBeTruthy(); return ((await response.json()) as { cosmetic: { id: string } }).cosmetic.id; } /** uploadChannelBackground ставит фон комнаты и возвращает id файла. */ async function uploadChannelBackground(api: APIRequestContext, channelId: string): Promise { const response = await repeat429(() => api.post(`${API}/channels/${channelId}/background`, { multipart: { file: { name: 'bg.png', mimeType: 'image/png', buffer: pngBytes(64, 64, [20, 20, 60]) }, }, }), ); expect(response.ok(), `фон комнаты: ${await response.text()}`).toBeTruthy(); const payload = (await response.json()) as { channel: { background_file_id?: string } }; return payload.channel.background_file_id ?? ''; } /** wavBytes собирает корректный WAV с тишиной: сервер проверяет тип файла. */ function wavBytes(): Buffer { const samples = 800; const data = Buffer.alloc(samples * 2); const header = Buffer.alloc(44); header.write('RIFF', 0, 'ascii'); header.writeUInt32LE(36 + data.length, 4); header.write('WAVE', 8, 'ascii'); header.write('fmt ', 12, 'ascii'); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(8_000, 24); header.writeUInt32LE(16_000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36, 'ascii'); header.writeUInt32LE(data.length, 40); return Buffer.concat([header, data]); } /** inviteCodeFromUrl вытаскивает код приглашения из ссылки сервера. */ function inviteCodeFromUrl(url: string): string { return url.split('/').pop() ?? ''; }