import { expect, test, type APIRequestContext, type APIResponse, type Browser, type BrowserContext, type Page, } from '@playwright/test'; import { createGuild, dropStaleGuilds, ensureProbe, openChannel, openGuild, postWithRetry, trackLoads, type Probe, } from './support'; /** * Realtime-матрица AGENT.md 11.6, пункты 8–13 (сообщения, непрочитанные, * presence/typing/голос, модерация, два устройства, RESUME): владелец 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-social.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' }; /** Второй пароль пробного участника: тест п.12 возвращает исходный. */ const MEMBER_TEMP_PASSWORD = 'Rt-Probe-Member-Temp-9x'; 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 Channel { id: string; name: string; type: string; } interface GuildFixture { guildId: string; guildName: string; textChannelId: string; textChannelName: string; secondChannelId: string; secondChannelName: string; voiceChannelId: string; voiceChannelName: string; /** Вторая голосовая комната: создана заранее, чтобы пришла в READY. */ secondVoiceChannelId: string; secondVoiceChannelName: string; } let owner: Account; let member: Account; test.describe('realtime: пункты 8–13 (чат, состояния, модерация, RESUME)', () => { 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('8. Сообщения: отправка, правка, реакции, пины и удаление — лента без перезагрузки', 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); session = await openSession(browser, member); const page = session.page; await openWorkspace(page, fixture); const loads = trackLoads(page); loads.reset(); const list = page.getByTestId('message-list'); // Отправка: сообщение владельца появляется в ленте B. const text = `Привет из realtime ${Date.now()}`; const messageId = await postMessage(ownerApi, fixture.textChannelId, text); const article = list.locator(`article[data-testid="message-${messageId}"]`); await expect(article).toContainText(text, { timeout: 20_000 }); // Правка: текст и пометка «(изменено)» приходят событием MESSAGE_UPDATE. const edited = await repeat429(() => ownerApi.patch(`${API}/channels/${fixture.textChannelId}/messages/${messageId}`, { data: { content: `${text} (правка)` }, }), ); expect(edited.ok(), `правка сообщения: ${await edited.text()}`).toBeTruthy(); await expect(article).toContainText('(правка)', { timeout: 20_000 }); await expect(article).toContainText('(изменено)', { timeout: 20_000 }); // Реакция: чип реакции появляется у B. const reaction = await repeat429(() => ownerApi.put( `${API}/channels/${fixture.textChannelId}/messages/${messageId}/reactions/${encodeURIComponent('👍')}`, ), ); expect(reaction.ok(), `реакция: ${await reaction.text()}`).toBeTruthy(); await expect(article.getByRole('button', { name: 'Реакция 👍: 1' })).toBeVisible({ timeout: 20_000, }); // Пин: счётчик закреплённых в шапке комнаты обновляется. const pinned = await repeat429(() => ownerApi.put(`${API}/channels/${fixture.textChannelId}/pins/${messageId}`), ); expect(pinned.ok(), `закрепление: ${await pinned.text()}`).toBeTruthy(); await expect( page.getByTestId('channel-header').getByRole('button', { name: 'Закреплённые сообщения' }), ).toContainText('1', { timeout: 20_000 }); // Снятие пина и удаление сообщения. const unpinned = await repeat429(() => ownerApi.delete(`${API}/channels/${fixture.textChannelId}/pins/${messageId}`), ); expect(unpinned.ok(), `снятие закрепления: ${await unpinned.text()}`).toBeTruthy(); await expect( page.getByTestId('channel-header').getByRole('button', { name: 'Закреплённые сообщения' }), ).not.toContainText('1', { timeout: 20_000 }); const removed = await repeat429(() => ownerApi.delete(`${API}/channels/${fixture.textChannelId}/messages/${messageId}`), ); expect(removed.ok(), `удаление сообщения: ${await removed.text()}`).toBeTruthy(); await expect(article).toHaveCount(0, { 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('9. Непрочитанные и упоминания синхронизируются между устройствами пользователя', async ({ browser, playwright, }) => { const ownerApi = await apiAs(playwright, owner.state); const memberApi = await apiAs(playwright, member.state); const sessions: Session[] = []; let fixture: GuildFixture | null = null; try { fixture = await prepareGuild(ownerApi, memberApi); // Два устройства одного пользователя: у каждого своя сессия. for (let device = 0; device < 2; device += 1) { sessions.push(await openSession(browser, member)); } const [first, second] = sessions as [Session, Session]; // Оба устройства открывают общую комнату, чтобы история загрузилась. for (const device of [first, second]) { await openWorkspace(device.page, fixture); } // Отметка прочтения: клиент ставит её, когда окно активно; здесь она // выставляется ручкой сервера и рассылается всем сессиям пользователя. const ack = await repeat429(() => memberApi.post(`${API}/channels/${fixture.textChannelId}/ack`, { data: {}, }), ); expect(ack.ok(), `отметка прочтения: ${await ack.text()}`).toBeTruthy(); for (const device of [first, second]) { await openChannel(device.page, fixture.secondChannelName); await expect(device.page.getByTestId('channel-header')).toContainText( fixture.secondChannelName, ); } const loads = trackLoads(first.page); loads.reset(); trackLoads(second.page).reset(); // Обычное сообщение: счётчик непрочитанных появляется на обоих // устройствах — история комнаты у них уже загружена. await postMessage(ownerApi, fixture.textChannelId, 'Первое сообщение'); const unreadBadge = `channel-unread-${fixture.textChannelId}`; await expect(first.page.getByTestId(unreadBadge)).toBeVisible({ timeout: 20_000 }); await expect(second.page.getByTestId(unreadBadge)).toBeVisible({ timeout: 20_000 }); // Упоминание: сервер считает mention_count и шлёт READ_STATE_UPDATE всем // сессиям пользователя; бейдж упоминаний важнее счётчика непрочитанных. // Отдельного центра уведомлений в клиенте нет (раздел «Уведомления» — // заглушка), поэтому проверяются бейджи комнат. await postMessage( ownerApi, fixture.textChannelId, `<@${member.id}> проверка упоминания ${Date.now()}`, ); const mentionBadge = `channel-mentions-${fixture.textChannelId}`; await expect(first.page.getByTestId(mentionBadge)).toBeVisible({ timeout: 20_000 }); await expect(second.page.getByTestId(mentionBadge)).toBeVisible({ timeout: 20_000 }); // Первое устройство читает комнату: подтверждение прочтения уходит на // сервер, тот рассылает READ_STATE_UPDATE — бейджи гаснут и на втором. await first.page.bringToFront(); await openChannel(first.page, fixture.textChannelName); await expect(first.page.getByTestId(mentionBadge)).toHaveCount(0, { timeout: 20_000 }); await expect(second.page.getByTestId(mentionBadge)).toHaveCount(0, { timeout: 20_000 }); await expect(second.page.getByTestId(unreadBadge)).toHaveCount(0, { timeout: 20_000 }); expect(loads.count(), 'бейджи менялись без полной загрузки документа').toBe(0); } finally { for (const device of sessions) { await device.context.close(); } await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); test('10. Presence, typing, голосовые состояния и перемещение между комнатами', 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); await makeFriends(ownerApi, memberApi, member.id); session = await openSession(browser, member); const page = session.page; await openWorkspace(page, fixture); const loads = trackLoads(page); loads.reset(); // Presence: список друзей свёрнут по умолчанию — раскрываем его и // проверяем, что смена статуса владельца доходит до друга сразу. const online = await repeat429(() => ownerApi.patch(`${API}/users/@me`, { data: { status: 'online' } }), ); expect(online.ok(), `статус «в сети»: ${await online.text()}`).toBeTruthy(); await page.getByRole('link', { name: 'Друзья' }).click(); await page.getByTestId('friends-list-toggle').click({ timeout: 15_000 }); const friendStatus = page.getByTestId(`friend-status-${owner.id}`).first(); await expect(friendStatus).toHaveAttribute('data-status', 'online', { timeout: 20_000 }); const status = await repeat429(() => ownerApi.patch(`${API}/users/@me`, { data: { status: 'dnd' } }), ); expect(status.ok(), `смена статуса: ${await status.text()}`).toBeTruthy(); await expect(friendStatus).toHaveAttribute('data-status', 'dnd', { timeout: 20_000 }); // Typing: набор текста владельцем показывает индикатор у B. await openGuild(page, fixture.guildName); await openChannel(page, fixture.textChannelName); const typing = await repeat429(() => ownerApi.post(`${API}/channels/${fixture.textChannelId}/typing`), ); expect(typing.ok(), `typing: ${await typing.text()}`).toBeTruthy(); await expect(page.getByTestId('typing-indicator')).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId('typing-indicator')).toContainText(owner.displayName, { timeout: 20_000, }); // Голос: B входит в первую комнату, владелец переносит его во вторую. await joinVoice(page, fixture.voiceChannelId, fixture.voiceChannelName); // Вторая комната создана до входа B: CHANNEL_CREATE приходит с // can_view:false, и созданная на лету комната в сайдбаре не появится. const moved = await repeat429(() => ownerApi.post(`${API}/guilds/${fixture.guildId}/voice-states/${member.id}/move`, { data: { channel_id: fixture.secondVoiceChannelId }, }), ); expect(moved.ok(), `перемещение участника: ${await moved.text()}`).toBeTruthy(); await expect( page .getByRole('list', { name: `Участники комнаты «${fixture.secondVoiceChannelName}»`, }) .getByRole('button', { name: new RegExp(member.displayName, 'u') }) .first(), ).toBeVisible({ timeout: 30_000 }); // Серверный мьют применяется сразу: в списке участников комнаты у B // появляется пометка модератора, а в самой комнате блокируется микрофон. await page .getByRole('link', { name: `Открыть комнату ${fixture.secondVoiceChannelName}`, exact: true, }) .click(); const mute = await repeat429(() => ownerApi.patch(`${API}/guilds/${fixture.guildId}/voice-states/${member.id}`, { data: { server_mute: true }, }), ); expect(mute.ok(), `серверный мьют: ${await mute.text()}`).toBeTruthy(); await expect( page .getByRole('list', { name: `Участники комнаты «${fixture.secondVoiceChannelName}»`, }) .getByRole('img', { name: 'Микрофон выключен модератором' }) .first(), ).toBeVisible({ timeout: 30_000 }); await expect( page.getByTestId(`voice-tile-${member.id}`).getByRole('img', { name: 'Микрофон выключен модератором', }), ).toBeVisible({ timeout: 30_000 }); expect(loads.count(), 'состояния менялись без полной загрузки документа').toBe(0); } finally { await session?.context.close(); await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); test('11. Кик, бан, тайм-аут и мут применяются сразу, списки обновляются', async ({ browser, playwright, }) => { const ownerApi = await apiAs(playwright, owner.state); const memberApi = await apiAs(playwright, member.state); const sessions: Session[] = []; let fixture: GuildFixture | null = null; let personal: { id: string; name: string } | null = null; try { fixture = await prepareGuild(ownerApi, memberApi); // Личный сервер B: без него после кика рейка пустеет, и клиент // возвращает удалённую плитку из устаревшего кэша `myGuilds` // (web/src/pages/app/AppLayout.tsx:112-119, см. отчёт). personal = await createGuild(memberApi, `${GUILD_PREFIX}Личный ${Date.now()}`); // B — цель модерации, A-браузер — наблюдатель списка участников. const memberSession = await openSession(browser, member); sessions.push(memberSession); const ownerSession = await openSession(browser, owner); sessions.push(ownerSession); await openWorkspace(memberSession.page, fixture); await openGuild(ownerSession.page, fixture.guildName); await openServerSettings(ownerSession.page, fixture.guildId); const memberRow = ownerSession.page .getByTestId('guild-members') .locator('li', { hasText: '@rtprobe2' }); await expect(memberRow).toBeVisible({ timeout: 20_000 }); // Серверный мьют отключает микрофон B. await joinVoice(memberSession.page, fixture.voiceChannelId, fixture.voiceChannelName); const mute = await repeat429(() => ownerApi.patch(`${API}/guilds/${fixture.guildId}/voice-states/${member.id}`, { data: { server_mute: true }, }), ); expect(mute.ok(), `серверный мьют: ${await mute.text()}`).toBeTruthy(); const mic = memberSession.page .getByRole('group', { name: /Управление голосом/u }) .getByRole('button') .first(); await expect(mic).toBeDisabled({ timeout: 30_000 }); // Тайм-аут: писать участник больше не может — сервер отвечает отказом. const until = new Date(Date.now() + 10 * 60_000).toISOString(); const timeout = await repeat429(() => ownerApi.patch(`${API}/guilds/${fixture.guildId}/members/${member.id}`, { data: { timeout_until: until }, }), ); expect(timeout.ok(), `тайм-аут: ${await timeout.text()}`).toBeTruthy(); const timedOut = await repeat429(() => memberApi.post(`${API}/channels/${fixture.textChannelId}/messages`, { data: { content: 'сообщение во время тайм-аута' }, }), ); expect(timedOut.ok(), 'тайм-аут запрещает отправку сообщений').toBeFalsy(); expect(timedOut.status(), 'код отказа по тайм-ауту').toBe(403); // Кик: сервер сообщает участнику, что сервер исчез, а список участников // у наблюдателя обновляется без перезагрузки. const kicked = await repeat429(() => ownerApi.delete(`${API}/guilds/${fixture.guildId}/members/${member.id}`), ); expect(kicked.ok(), `кик: ${await kicked.text()}`).toBeTruthy(); await expect( memberSession.page.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true, }), ).toHaveCount(0, { timeout: 20_000 }); await expect(memberRow).toHaveCount(0, { timeout: 20_000 }); // Возврат в сервер: он на время открывается — приглашения тратят // суточную квоту 10/24ч (AGENT.md 8.6). Участник снова в составе, и // плитка сервера возвращается в рейку B без перезагрузки: по GUILD_CREATE // клиент перечитывает список серверов (web/src/stores/gateway.ts, // addGuildFromRest), а не берёт устаревший кэш myGuilds. await setPublic(ownerApi, fixture.guildId, true); const rejoin = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`); expect(rejoin.ok(), `возврат в сервер: ${await rejoin.text()}`).toBeTruthy(); await expect .poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 }) .toContain(member.id); await expect( memberSession.page.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true, }), ).toBeVisible({ timeout: 20_000 }); await setPublic(ownerApi, fixture.guildId, false); // Бан: участник снова исключён, попал в список банов и не может вернуться // даже в открытый сервер — сервер отвечает 403 guild.banned. const banned = await ownerApi.put(`${API}/guilds/${fixture.guildId}/bans/${member.id}`, { data: { reason: 'realtime e2e' }, }); expect(banned.ok(), `бан: ${await banned.text()}`).toBeTruthy(); await expect .poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 }) .not.toContain(member.id); const bans = await ownerApi.get(`${API}/guilds/${fixture.guildId}/bans`); const banList = ((await bans.json()) as { bans: { user_id: string }[] }).bans; expect( banList.map((ban) => ban.user_id), 'забаненный участник в списке банов', ).toContain(member.id); await setPublic(ownerApi, fixture.guildId, true); const blocked = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`); expect(blocked.ok(), 'забаненный не возвращается в открытый сервер').toBeFalsy(); expect(blocked.status(), 'код отказа для забаненного').toBe(403); } finally { for (const device of sessions) { await device.context.close(); } await dropGuild(memberApi, personal?.id ?? '', MEMBER_LOGIN.password); await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); test('13. После реконнекта RESUME досылает пропущенное, состояние совпадает с сервером', 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); // Отдельная сессия: тест 12 отзывает общие сессии файла (logout-all). session = await freshSession( browser, playwright, member, member.password, captureGatewayFrames, ); const page = session.page; await openWorkspace(page, fixture); const loads = trackLoads(page); loads.reset(); // Разрыв связи: сокет шлюза закрывается, сеть выключается — клиент // уходит в переподключение и не может получить события. await page.evaluate(() => { const globalWindow = window as unknown as { __sockets?: WebSocket[] }; for (const socket of globalWindow.__sockets ?? []) { socket.close(); } }); await session.context.setOffline(true); const missed: string[] = []; for (let index = 0; index < 3; index += 1) { missed.push( await postMessage(ownerApi, fixture.textChannelId, `Пропущенное сообщение ${index}`), ); } await session.context.setOffline(false); // Догон: все три сообщения появляются в ленте B без перезагрузки. const list = page.getByTestId('message-list'); for (const id of missed) { await expect(list.locator(`article[data-testid="message-${id}"]`)).toBeVisible({ timeout: 60_000, }); } // Состояние совпадает с сервером: набор сообщений в ленте равен ответу REST. const server = await repeat429(() => ownerApi.get(`${API}/channels/${fixture.textChannelId}/messages?limit=50`), ); const serverIds = ((await server.json()) as { messages: { id: string }[] }).messages.map( (message) => message.id, ); const rendered = await list .locator('article[data-message-id]') .evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-message-id') ?? '')); for (const id of serverIds) { expect(rendered, `сообщение ${id} есть в ленте после RESUME`).toContain(id); } // Клиент действительно возобновил сессию, а не получил READY заново. const frames = await page.evaluate( () => (window as unknown as { __frames?: string[] }).__frames ?? [], ); const sent = await page.evaluate( () => (window as unknown as { __sent?: string[] }).__sent ?? [], ); expect( sent.some((frame) => frame.includes('"op":3')), 'клиент отправил RESUME', ).toBe(true); expect( frames.some((frame) => frame.includes('"t":"RESUMED"')), 'сервер подтвердил догон событий', ).toBe(true); expect(loads.count(), 'догон событий прошёл без полной загрузки документа').toBe(0); } finally { await session?.context.close(); await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); test('12. Два устройства: смена пароля и выход на всех отражаются на втором', async ({ browser, playwright, }) => { // Тест идёт последним: logout-all и смена пароля отзывают подготовленные // сессии файла, поэтому остальным тестам они должны остаться живыми. // // Оба действия подтверждаются на втором устройстве без перезагрузки — // сервер шлёт INVALID_SESSION, клиент сразу уходит на экран входа, даже // когда открыт сервер (AGENT.md 11.6). const ownerApi = await apiAs(playwright, owner.state); const memberApi = await apiAs(playwright, member.state); const sessions: Session[] = []; let fixture: GuildFixture | null = null; // Устройство, сменившее пароль: с него возвращаем исходный в finally, чтобы // падение теста не оставило постоянный пробный аккаунт с временным паролем. let passwordDevice: Page | null = null; let passwordChanged = false; try { fixture = await prepareGuild(ownerApi, memberApi); // Две готовые сессии пользователя — два устройства без лишних входов. const first = await sessionFrom(browser, member.browserState); const second = await sessionFrom(browser, member.state); sessions.push(first, second); await openWorkspace(first.page, fixture); await openWorkspace(second.page, fixture); // Выход на всех устройствах: второе устройство теряет сессию сразу. await logoutAll(first.page); const afterLogout = await second.page.evaluate(async () => { const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' }); return response.status; }); expect(afterLogout, 'сессия второго устройства отозвана').toBe(401); await expect(second.page).toHaveURL(/\/login/u, { timeout: 30_000 }); // Смена пароля: два новых устройства, пароль меняется через интерфейс. const third = await freshSession(browser, playwright, member); const fourth = await freshSession(browser, playwright, member); sessions.push(third, fourth); await openWorkspace(third.page, fixture); await openWorkspace(fourth.page, fixture); passwordDevice = third.page; await changePassword(third.page, member.password, MEMBER_TEMP_PASSWORD); passwordChanged = true; const revoked = await fourth.page.evaluate(async () => { const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' }); return response.status; }); expect(revoked, 'сессия второго устройства отозвана сменой пароля').toBe(401); await expect(fourth.page).toHaveURL(/\/login/u, { timeout: 30_000 }); } finally { if (passwordChanged && passwordDevice !== null) { const restored = await restoreMemberPassword(passwordDevice, member.password); expect( restored, `пароль пробного аккаунта ${MEMBER_LOGIN.username} возвращён (${restored})`, ).toBe(200); } for (const device of sessions) { await device.context.close(); } await cleanup(ownerApi, fixture); await ownerApi.dispose(); await memberApi.dispose(); } }); }); /** * openSession открывает браузерный контекст из подготовленной сессии: вход * выполняется один раз на файл, поэтому прогон не упирается в лимит входов * (5 в минуту на IP, AGENT.md 8.6). */ async function openSession( browser: Browser, account: Account, onContext?: (context: BrowserContext) => Promise, ): Promise { const context = await browser.newContext({ storageState: account.browserState }); if (onContext !== undefined) { await onContext(context); } const page = await context.newPage(); await page.goto('/app'); await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 }); return { context, page }; } /** * freshSession поднимает ещё одну независимую сессию аккаунта (второе * устройство): вход идёт по REST, чтобы не тратить лимит входов из браузера. */ async function freshSession( browser: Browser, playwright: Requester, account: Account, password = account.password, onContext?: (context: BrowserContext) => Promise, ): Promise { const api = await playwright.request.newContext({ baseURL: BASE_URL }); try { const login = await postWithRetry(api, `${API}/auth/login`, { email: account.email, password, }); expect(login.ok(), `вход ${account.username}: ${await login.text()}`).toBeTruthy(); const context = await browser.newContext({ storageState: await api.storageState() }); if (onContext !== undefined) { await onContext(context); } const page = await context.newPage(); await page.goto('/app'); await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 }); return { context, page }; } finally { await api.dispose(); } } /** 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 создаёт сервер от имени владельца, две текстовые и голосовую * комнаты и приглашает участника. */ async function prepareGuild( ownerApi: APIRequestContext, memberApi: APIRequestContext, ): Promise { 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 secondVoice = await createChannel(ownerApi, guild.id, { name: 'Голос 2', type: 'voice' }); const second = await createChannel(ownerApi, guild.id, { name: 'Вторая', type: 'text' }); // Вступление без приглашения: сервер на время делается открытым, потому что // квота приглашений — 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(() => memberApi.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(); return { guildId: guild.id, guildName, textChannelId: text?.id ?? '', textChannelName: text?.name ?? '', secondChannelId: second.id, secondChannelName: second.name, voiceChannelId: voice.id, voiceChannelName: voice.name, secondVoiceChannelId: secondVoice.id, secondVoiceChannelName: secondVoice.name, }; } /** dropGuild удаляет сервер по id: мусор на стенде не остаётся. */ async function dropGuild(api: APIRequestContext, guildId: string, password: string): Promise { if (guildId === '') { return; } await stepUp(api, password).catch(() => undefined); await api.delete(`${API}/guilds/${guildId}`).catch(() => undefined); } /** cleanup удаляет тестовый сервер: мусор на стенде не остаётся. */ async function cleanup(ownerApi: APIRequestContext, fixture: GuildFixture | null): Promise { if (fixture === null) { return; } await stepUp(ownerApi, OWNER_LOGIN.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 }); } /** * 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); } } } /** changePassword меняет пароль пользователя через настройки безопасности. */ async function changePassword(page: Page, current: string, next: string): Promise { await page.goto('/settings/account/security'); const content = page.getByTestId('settings-content'); await expect(content).toBeVisible({ timeout: 20_000 }); await content.getByLabel('Текущий пароль', { exact: true }).fill(current); await content.getByLabel('Новый пароль', { exact: true }).fill(next); await content.getByLabel('Повторите новый пароль', { exact: true }).fill(next); await content.getByRole('button', { name: 'Сменить пароль' }).click(); await expect(content.getByRole('status')).toContainText('Пароль изменён', { timeout: 20_000 }); } /** * restoreMemberPassword возвращает пробному участнику исходный пароль с * устройства, которое его сменило. Возврат идёт из finally: если он не удался, * постоянный аккаунт останется с временным паролем и следующие прогоны файла * не смогут войти — поэтому тест обязан сообщить об этом явно. */ async function restoreMemberPassword(page: Page, password: string): Promise { return page.evaluate( async (payload: { current: string; next: string }) => { const response = await fetch('/api/v1/users/@me/password', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ current_password: payload.current, new_password: payload.next }), }); return response.status; }, { current: MEMBER_TEMP_PASSWORD, next: password }, ); } /** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */ async function sessionFrom(browser: Browser, state: StorageState): Promise { const context = await browser.newContext({ storageState: state }); const page = await context.newPage(); await page.goto('/app'); await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 }); return { context, page }; } /** logoutAll выходит на всех устройствах через настройки безопасности. */ async function logoutAll(page: Page): Promise { await page.goto('/settings/account/security'); const content = page.getByTestId('settings-content'); await expect(content).toBeVisible({ timeout: 20_000 }); // Подтверждение выхода — системный диалог браузера. page.once('dialog', (dialog) => void dialog.accept()); await content .getByRole('button', { name: 'Выйти на всех устройствах' }) .click({ timeout: 15_000 }); await expect(page).toHaveURL(/\/login/u, { timeout: 20_000 }); } /** makeFriends делает владельца и участника друзьями: нужен presence-канал. */ async function makeFriends( ownerApi: APIRequestContext, memberApi: APIRequestContext, memberId: string, ): Promise { const request = await repeat429(() => ownerApi.post(`${API}/users/@me/relationships`, { data: { user_id: memberId }, }), ); expect(request.ok(), `заявка в друзья: ${await request.text()}`).toBeTruthy(); const accept = await repeat429(() => memberApi.post(`${API}/users/@me/relationships/${owner.id}/accept`), ); if (accept.ok()) { return; } // Повторный прогон: заявка уже принята, дружба существует. const list = await repeat429(() => memberApi.get(`${API}/users/@me/relationships`)); const payload = (await list.json()) as { friends: { user_id: string }[] }; expect( payload.friends.map((friend) => friend.user_id), `дружба с ${owner.username} установлена`, ).toContain(owner.id); } /** * captureGatewayFrames запоминает кадры шлюза и открытые сокеты: по ним * проверяется RESUME и разрыв связи. */ async function captureGatewayFrames(context: BrowserContext): Promise { await context.addInitScript(() => { const Original = window.WebSocket; const globalWindow = window as unknown as { __frames?: string[]; __sent?: string[]; __sockets?: WebSocket[]; }; globalWindow.__frames = []; globalWindow.__sent = []; globalWindow.__sockets = []; window.WebSocket = function Patched(...args: ConstructorParameters) { const socket = new Original(...args); globalWindow.__sockets?.push(socket); socket.addEventListener('message', (event: MessageEvent) => { const text = String(event.data); if (text.includes('RESUMED') || text.includes('READY')) { globalWindow.__frames?.push(text.slice(0, 400)); } }); // Исходящие кадры: по ним видно, что клиент отправил именно RESUME. const send = socket.send.bind(socket); socket.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => { if (typeof data === 'string') { globalWindow.__sent?.push(data.slice(0, 200)); } send(data); }; return socket; } as unknown as typeof WebSocket; window.WebSocket.prototype = Original.prototype; }); } 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; } 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; } /** guildMemberIds возвращает состав участников сервера. */ async function guildMemberIds(api: APIRequestContext, guildId: string): Promise { const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/members`)); expect(response.ok(), `участники сервера: ${await response.text()}`).toBeTruthy(); const payload = (await response.json()) as { members: { user_id: string }[] }; return payload.members.map((item) => item.user_id); } /** setPublic включает или выключает открытый вход на сервер. */ async function setPublic( api: APIRequestContext, guildId: string, isPublic: boolean, ): Promise { const response = await repeat429(() => api.patch(`${API}/guilds/${guildId}`, { data: { public: isPublic } }), ); 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; }