Files
glchat/web/e2e/realtime.spec.ts
T
grendervill ab855fbd4f test(e2e): матрица realtime §11.6 — пункты 1–13
Два спектакля на общем модуле `web/e2e/support.ts` (запуск только с
`GLCHAT_E2E_REALTIME=1`):
- `realtime.spec.ts` — пункты 1–7: роли и права, приватная комната по роли,
  профиль и ники, оформление сервера, эмодзи/звуки/косметика, инвайты, комнаты;
- `realtime-social.spec.ts` — пункты 8–13: сообщения, непрочитанные и
  упоминания между устройствами, presence/typing/голос, модерация, два
  устройства, RESUME после реконнекта.

Владелец действует по REST, второй браузер видит результат через Gateway;
полные загрузки документа запрещены и проверяются `trackLoads`. Матрица нашла
четыре дефекта продукта (исправлены в `d241714` и `7ec9dbf`).
2026-09-22 21:19:37 +03:00

1192 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ReturnType<APIRequestContext['storageState']>>;
/** Открытая браузерная сессия: контекст и страница приложения. */
interface Session {
context: BrowserContext;
page: Page;
}
/** Фабрика REST-контекстов: сюда приходит фикстура `playwright` из теста. */
interface Requester {
request: {
newContext(options: {
baseURL: string;
storageState: StorageState;
}): Promise<APIRequestContext>;
};
}
/** Аккаунт стенда: профиль пробного пользователя и сохранённая сессия 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<string[]> => {
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<string[]> {
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<void> {
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<StorageState> {
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<string> {
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<APIResponse>, attempts = 20): Promise<APIResponse> {
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<Probe> {
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<APIRequestContext> {
return playwright.request.newContext({ baseURL: BASE_URL, storageState: state });
}
/** stepUp подтверждает личность: чувствительные ручки требуют свежий step-up. */
async function stepUp(api: APIRequestContext, password: string): Promise<void> {
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<GuildFixture> {
// Сервер создаёт либо владелец-исполнитель 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<void> {
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<void> {
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<void> {
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<Session> {
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<void> {
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<Channel[]> {
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<string[]> {
const channels = await listChannels(api, guildId);
return channels.map((channel) => channel.id);
}
async function listRoles(api: APIRequestContext, guildId: string): Promise<Role[]> {
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<string, unknown>,
): Promise<Role> {
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<string, unknown>,
): Promise<void> {
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<void> {
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<void> {
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<string, unknown>,
): Promise<Channel> {
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<string, unknown>,
): Promise<void> {
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<string> {
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<string> {
const channels = await listChannels(api, guildId);
return channels.find((channel) => channel.id === channelId)?.description ?? '';
}
/** roleIds возвращает порядок ролей в списке (сервер сортирует по позиции). */
async function roleIds(page: Page): Promise<string[]> {
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<string[]> {
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<string> {
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<string, string | undefined> };
return payload.guild[`${kind}_file_id`] ?? '';
}
/** uploadEmoji загружает кастомный эмодзи сервера. */
async function uploadEmoji(api: APIRequestContext, guildId: string, name: string): Promise<string> {
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<string> {
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<string> {
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<string> {
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() ?? '';
}