import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; import { apiAs, createChannel, createGuild, createRole, displayName, dropStaleGuilds, ensureProbe, listChannels, listRoles, openApp, openGuild, pngBytes, postWithRetry, repeat429, signIn, stepUp, trackLoads, type Account, type ProbeLogin, type Session, } from './support'; /** * Realtime-матрица AGENT.md 11.6 для остатка Фазы 7: иконка и имя групповой * беседы (D-070, D-075) и личные шаблоны серверов (7.3). Действие A видно * второму браузеру без F5 и повторного входа — за прогон ни одной полной * загрузки документа (`trackLoads` = 0). Запуск только по флагу, тест требует * стенда: * * GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \ * PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \ * npx playwright test e2e/realtime-phase7.spec.ts */ const enabled = process.env.GLCHAT_E2E_REALTIME === '1'; const API = '/api/v1'; const BASE_URL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; /** Префикс тестовых данных: по нему убираются серверы, шаблоны и группы прошлых прогонов. */ const PREFIX = 'Realtime E2E '; /** Постоянные пробные аккаунты: A — владелец, B — второй браузер, C — третий участник. */ const OWNER_LOGIN: ProbeLogin = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' }; const MEMBER_LOGIN: ProbeLogin = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' }; const THIRD_LOGIN: ProbeLogin = { username: 'rtprobe3', password: 'Rt-Probe-Third-9x' }; let owner: Account; let member: Account; let third: Account; test.describe('realtime: иконка и имя групповой беседы, личные шаблоны (Фаза 7)', () => { 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: BASE_URL }); const memberApi = await playwright.request.newContext({ baseURL: BASE_URL }); const thirdApi = await playwright.request.newContext({ baseURL: BASE_URL }); try { // Лимит входов — 5 в минуту на IP (AGENT.md 8.6): входы разносим по времени. const ownerProbe = await ensureProbe(ownerApi, OWNER_LOGIN.username, OWNER_LOGIN.password); await new Promise((resolve) => setTimeout(resolve, 2_000)); const memberProbe = await ensureProbe( memberApi, MEMBER_LOGIN.username, MEMBER_LOGIN.password, ); await new Promise((resolve) => setTimeout(resolve, 2_000)); const thirdProbe = await ensureProbe(thirdApi, THIRD_LOGIN.username, THIRD_LOGIN.password); const ownerName = await displayName(ownerApi); const memberName = await displayName(memberApi); const thirdName = await displayName(thirdApi); // Уборка данных прошлых прогонов: удаление серверов требует свежий step-up. await stepUp(ownerApi, OWNER_LOGIN.password); await dropStaleGuilds(ownerApi, PREFIX); await dropStaleTemplates(ownerApi, PREFIX); await dropStaleGroups(ownerApi, PREFIX, ownerProbe.id); await stepUp(memberApi, MEMBER_LOGIN.password); await dropStaleGuilds(memberApi, PREFIX); // Браузерные сессии берём из тех же api-контекстов: лишние входы не // тратятся, а наблюдатель входит формой уже внутри теста. owner = { ...ownerProbe, displayName: ownerName, state: await ownerApi.storageState(), browserState: await ownerApi.storageState(), }; member = { ...memberProbe, displayName: memberName, state: await memberApi.storageState(), browserState: await memberApi.storageState(), }; third = { ...thirdProbe, displayName: thirdName, state: await thirdApi.storageState(), browserState: await thirdApi.storageState(), }; } finally { await ownerApi.dispose(); await memberApi.dispose(); await thirdApi.dispose(); } }); test('26. Иконка и имя групповой беседы: у наблюдателя без перезагрузки', async ({ browser, playwright, }) => { const ownerApi = await apiAs(playwright, owner.state); const sessions: Session[] = []; let groupId = ''; try { // Группа — это минимум три человека (AGENT.md 7.8): владелец и два гостя. const created = await postWithRetry(ownerApi, `${API}/users/@me/channels/group`, { name: `${PREFIX}группа`, recipient_ids: [member.id, third.id], }); expect(created.ok(), `групповая беседа: ${await created.text()}`).toBeTruthy(); groupId = ((await created.json()) as { channel: { id: string } }).channel.id; // Владелец — на подготовленной сессии, наблюдатель входит формой: пути разные. sessions.push(await openApp(browser, owner.browserState)); sessions.push(await signIn(browser, { email: member.email, password: member.password })); const [actor, observer] = sessions as [Session, Session]; await openGroupConversation(actor.page, groupId); await openGroupConversation(observer.page, groupId); // Считаем полные загрузки документа: F5 в сценарии быть не должно. const loads = trackLoads(observer.page); loads.reset(); // Владелец ставит иконку беседы через меню в шапке. await actor.page.getByTestId('dm-owner-menu').click(); await actor.page.getByTestId('dm-icon-upload').click(); await actor.page.getByTestId('dm-icon-input').setInputFiles({ name: 'group-icon.png', mimeType: 'image/png', buffer: pngBytes(64, 64, [30, 110, 210]), }); const actorHeader = actor.page.getByTestId('dm-group-header'); await expect(actorHeader.locator('img')).toHaveAttribute('src', /\/files\/\d+/u, { timeout: 30_000, }); // Наблюдатель: иконка приходит событием DM_CHANNEL_UPDATE в шапку и список. const observerHeader = observer.page.getByTestId('dm-group-header'); await expect(observerHeader.locator('img')).toHaveAttribute('src', /\/files\/\d+/u, { timeout: 20_000, }); await expect(observer.page.getByTestId(`dm-link-${groupId}`).locator('img')).toHaveAttribute( 'src', /\/files\/\d+/u, { timeout: 20_000 }, ); // Участник-не-владелец меню владельца не получает: права проверяет сервер. await expect(observer.page.getByTestId('dm-owner-menu')).toHaveCount(0); // Переименование: владелец через диалог, наблюдатель видит новое имя. const renamed = `${PREFIX}кино ${Date.now()}`; await actor.page.getByTestId('dm-owner-menu').click(); await actor.page.getByTestId('dm-group-rename').click(); const dialog = actor.page.getByRole('dialog', { name: 'Переименовать беседу' }); await expect(dialog).toBeVisible({ timeout: 20_000 }); // Поле открывается с текущим именем — его и правит владелец. await expect(dialog.getByLabel('Название группы')).toHaveValue(`${PREFIX}группа`); await dialog.getByLabel('Название группы').fill(renamed); await dialog.getByRole('button', { name: 'Сохранить' }).click(); await expect(dialog).toBeHidden({ timeout: 20_000 }); await expect(actorHeader).toContainText(renamed, { timeout: 20_000 }); await expect(observerHeader).toContainText(renamed, { timeout: 20_000 }); await expect(observer.page.getByTestId(`dm-link-${groupId}`)).toContainText(renamed, { timeout: 20_000, }); // Имя сохранено на сервере, а не только нарисовано в браузере владельца. const list = await repeat429(() => ownerApi.get(`${API}/users/@me/channels`)); expect(list.ok(), `список бесед: ${await list.text()}`).toBeTruthy(); const channels = ((await list.json()) as { channels: { id: string; name: string }[] }) .channels; expect(channels.find((channel) => channel.id === groupId)?.name).toBe(renamed); // Ни один шаг матрицы не потребовал перезагрузки страницы. expect(loads.count(), 'наблюдатель обновлялся без полной загрузки документа').toBe(0); } finally { for (const session of sessions) { await session.context.close(); } if (groupId !== '') { await dropGroup(ownerApi, { id: groupId, member_ids: [member.id, third.id] }, owner.id); } await ownerApi.dispose(); } }); test('27. Личные шаблоны: сервер по шаблону на другом устройстве без перезагрузки', async ({ browser, playwright, }) => { const ownerApi = await apiAs(playwright, owner.state); const sessions: Session[] = []; const stamp = Date.now(); const sourceName = `${PREFIX}источник ${stamp}`; const templateName = `${PREFIX}шаблон ${stamp}`; const roomName = `tpl-room-${stamp}`; const roleName = `tpl-role-${stamp}`; const createdName = `${PREFIX}по шаблону ${stamp}`; let sourceGuildId = ''; let createdGuildId = ''; let templateId = ''; try { // Исходный сервер с отличимой структурой: по ней видно, что новый сервер // собран именно из личного шаблона. const source = await createGuild(ownerApi, sourceName); sourceGuildId = source.id; await createChannel(ownerApi, sourceGuildId, { name: roomName, type: 'text' }); await createRole(ownerApi, sourceGuildId, { name: roleName, permissions: 'VIEW_CHANNEL|SEND_MESSAGES', }); // Первое устройство: сохраняем структуру личным шаблоном в настройках. const device1 = await openApp(browser, owner.browserState); sessions.push(device1); await openGuild(device1.page, sourceName); await device1.page.getByLabel('Настройки сервера').click(); const form = device1.page.getByTestId('guild-templates-form'); await expect(form).toBeVisible({ timeout: 30_000 }); await form.getByLabel('Название шаблона').fill(templateName); await form.getByRole('button', { name: 'Сохранить шаблон' }).click(); await expect(device1.page.getByText('Шаблон сохранён.')).toBeVisible({ timeout: 20_000 }); // id шаблона берём по REST: им же наблюдатель выберет его в окне создания. const mine = await repeat429(() => ownerApi.get(`${API}/guild-templates/mine`)); expect(mine.ok(), `свои шаблоны: ${await mine.text()}`).toBeTruthy(); const templates = ((await mine.json()) as { templates: { id: string; name: string }[] }) .templates; templateId = templates.find((template) => template.name === templateName)?.id ?? ''; expect(templateId, 'шаблон сохранён на сервере').not.toBe(''); // Второе устройство того же пользователя: свой вход формой. const device2 = await signIn(browser, { email: owner.email, password: owner.password }); sessions.push(device2); const loads = trackLoads(device2.page); loads.reset(); // Свои шаблоны видны в окне создания сервера: список читается при // открытии, страницу перезагружать не нужно (у личных шаблонов нет // отдельного события шлюза — они приватны владельцу). await device2.page.getByLabel('Создать сервер').click(); const createDialog = device2.page.getByRole('dialog', { name: 'Создать сервер' }); const option = createDialog.getByTestId(`guild-template-personal-${templateId}`); await expect(option).toBeVisible({ timeout: 20_000 }); await option.getByRole('radio').click(); await createDialog.getByLabel('Название сервера').fill(createdName); await createDialog.getByRole('button', { name: 'Создать' }).click(); await expect(device2.page.getByTestId('guild-header')).toContainText(createdName, { timeout: 30_000, }); createdGuildId = device2.page.url().split('/app/')[1]?.split('/')[0] ?? ''; expect(createdGuildId, 'новый сервер открылся в приложении').not.toBe(''); // Структура нового сервера совпала с шаблоном: комната и роль на месте. const createdChannels = await listChannels(ownerApi, createdGuildId); expect(createdChannels.map((channel) => channel.name)).toContain(roomName); const createdRoles = await listRoles(ownerApi, createdGuildId); expect(createdRoles.map((role) => role.name)).toContain(roleName); expect(loads.count(), 'окно создания сервера открылось без полной загрузки документа').toBe( 0, ); } finally { for (const session of sessions) { await session.context.close(); } await stepUp(ownerApi, OWNER_LOGIN.password).catch(() => undefined); for (const guildId of [createdGuildId, sourceGuildId]) { if (guildId !== '') { await repeat429(() => ownerApi.delete(`${API}/guilds/${guildId}`)).catch(() => undefined); } } if (templateId !== '') { await repeat429(() => ownerApi.delete(`${API}/guild-templates/${templateId}`)).catch( () => undefined, ); } await ownerApi.dispose(); } }); }); /** Открывает раздел «Друзья» и переходит в беседу по ссылке сайдбара. */ async function openGroupConversation(page: Page, channelId: string): Promise { await page.getByRole('link', { name: 'Друзья' }).click(); const link = page.getByTestId(`dm-link-${channelId}`); await expect(link).toBeVisible({ timeout: 30_000 }); await link.click(); await expect(page.getByTestId('dm-group-header')).toBeVisible({ timeout: 30_000 }); } /** Беседа в списке `/users/@me/channels`: для уборки нужны её участники. */ interface DmChannelRef { id: string; name?: string; is_group?: boolean; member_ids?: string[]; recipients?: { user_id: string; is_self?: boolean }[]; } /** * dropGroup убирает тестовую группу: иконку снимаем до роспуска (иначе файл * останется сиротой), участников удаляет владелец, а беседа без участников * удаляется на сервере целиком. */ async function dropGroup( api: APIRequestContext, channel: DmChannelRef, ownerId: string, ): Promise { await repeat429(() => api.delete(`${API}/channels/${channel.id}/icon`)).catch(() => undefined); const others = channel.member_ids ?? (channel.recipients ?? []) .map((recipient) => recipient.user_id) .filter((userId) => userId !== ownerId); for (const userId of others) { if (userId === ownerId) { continue; } await repeat429(() => api.delete(`${API}/channels/${channel.id}/recipients/${userId}`)).catch( () => undefined, ); } await repeat429(() => api.delete(`${API}/channels/${channel.id}/recipients/${ownerId}`)).catch( () => undefined, ); } /** dropStaleGroups убирает группы прошлых прогонов: их имена начинаются с префикса. */ async function dropStaleGroups( api: APIRequestContext, prefix: string, ownerId: string, ): Promise { const response = await repeat429(() => api.get(`${API}/users/@me/channels`)); if (!response.ok()) { return; } const { channels } = (await response.json()) as { channels: DmChannelRef[] }; for (const channel of channels) { if (channel.is_group === true && (channel.name ?? '').startsWith(prefix)) { await dropGroup(api, channel, ownerId); } } } /** dropStaleTemplates убирает личные шаблоны прошлых прогонов (лимит — 20). */ async function dropStaleTemplates(api: APIRequestContext, prefix: string): Promise { const response = await repeat429(() => api.get(`${API}/guild-templates/mine`)); if (!response.ok()) { return; } const { templates } = (await response.json()) as { templates: { id: string; name: string }[] }; for (const template of templates) { if (template.name.startsWith(prefix)) { await repeat429(() => api.delete(`${API}/guild-templates/${template.id}`)).catch( () => undefined, ); } } }