1192 lines
55 KiB
TypeScript
1192 lines
55 KiB
TypeScript
|
|
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() ?? '';
|
|||
|
|
}
|