Files
glchat/web/e2e/support.ts
T
grendervill 71c0107b87 test(web): общие помощники живых e2e в support.ts
Новые спеки (§11.5, личные серверы и шапка сервера) используют тот же
набор шагов, что матрица 11.6, но её помощники лежали внутри
`realtime.spec.ts`. Вынес в `support.ts`, чтобы не копировать их в третий
раз: сессии и лимит входов, повтор при 429, step-up (в том числе с 2FA
админа), подготовка сервера и уборка, комнаты, роли, сообщения, звуки,
личные беседы, друзья и блокировки.
2026-09-26 11:06:36 +03:00

770 lines
33 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 { createHmac } from 'node:crypto';
import { deflateSync } from 'node:zlib';
import {
expect,
type APIRequestContext,
type APIResponse,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
/**
* support.ts — общие помощники живых e2e против развёрнутого инстанса
* (AGENT.md 11.2, 11.6): вход по 2FA, повтор при лимите частоты, постоянные
* тестовые аккаунты, подготовка сервера и генерация картинок для загрузок.
*/
/** Значения окружения: без них живые тесты пропускаются. */
export function adminCredentials(): { email: string; password: string; totp: string } {
return {
email: process.env.GLCHAT_ADMIN_EMAIL ?? '',
password: process.env.GLCHAT_ADMIN_PASSWORD ?? '',
totp: process.env.GLCHAT_ADMIN_TOTP ?? '',
};
}
/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */
export function totp(secret: string, now = Date.now()): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const symbol of secret.replace(/=+$/u, '').toUpperCase()) {
const index = alphabet.indexOf(symbol);
if (index < 0) {
continue;
}
bits += index.toString(2).padStart(5, '0');
}
const bytes: number[] = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(Number.parseInt(bits.slice(i, i + 8), 2));
}
const counter = Math.floor(now / 1000 / 30);
const buffer = Buffer.alloc(8);
buffer.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
buffer.writeUInt32BE(counter % 2 ** 32, 4);
const digest = createHmac('sha1', Buffer.from(bytes)).update(buffer).digest();
const offset = digest.readUInt8(digest.length - 1) & 0x0f;
const value =
((digest.readUInt8(offset) & 0x7f) << 24) |
(digest.readUInt8(offset + 1) << 16) |
(digest.readUInt8(offset + 2) << 8) |
digest.readUInt8(offset + 3);
return (value % 1_000_000).toString().padStart(6, '0');
}
/**
* postWithRetry повторяет запрос при срабатывании лимита частоты: сервер
* отвечает `rate_limited` и `retry_after_ms`, а тест не должен падать из-за
* защиты, которая как раз и обязана работать (AGENT.md 9.4).
*/
export async function postWithRetry(
api: APIRequestContext,
path: string,
data: Record<string, unknown>,
attempts = 6,
): Promise<Awaited<ReturnType<APIRequestContext['post']>>> {
let response = await api.post(path, { data });
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number };
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 20_000);
await new Promise((resolve) => setTimeout(resolve, wait + 200));
response = await api.post(path, { data });
}
return response;
}
/**
* finishOnboarding помечает первичную настройку пройденной: иначе AuthGuard
* уводит браузер с `/app` на `/onboarding`.
*/
export async function finishOnboarding(api: APIRequestContext): Promise<void> {
const me = await api.get('/api/v1/users/@me');
expect(me.ok(), `текущий пользователь: ${await me.text()}`).toBeTruthy();
const { user } = (await me.json()) as {
user: { onboarding_completed: boolean; username: string; display_name?: string | null };
};
if (user.onboarding_completed) {
return;
}
const done = await api.post('/api/v1/users/@me/onboarding/complete', {
data: { display_name: user.display_name ?? user.username },
});
expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy();
}
/** loginAsAdmin логинит api-контекст под инстанс-администратором. */
export async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
const admin = adminCredentials();
const response = await postWithRetry(api, '/api/v1/auth/login', {
email: admin.email,
password: admin.password,
totp_code: totp(admin.totp),
});
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { user: { id: string } };
return payload.user;
}
export interface Probe {
id: string;
username: string;
email: string;
password: string;
}
/**
* ensureProbe логинит постоянный тестовый аккаунт, а если его ещё нет —
* регистрирует. Один и тот же аккаунт переиспользуется между прогонами: иначе
* каждый запуск оставлял бы в базе нового пользователя.
*/
export async function ensureProbe(
api: APIRequestContext,
username: string,
password: string,
): Promise<Probe> {
const email = `${username}@gl.mhspx.su`;
const credentials = { email, password };
let login = await postWithRetry(api, '/api/v1/auth/login', credentials);
if (!login.ok() && login.status() !== 429) {
const registered = await postWithRetry(api, '/api/v1/auth/register', {
username,
...credentials,
});
// 409 — аккаунт уже существует (например, гонка прогонов): просто входим.
expect(
registered.ok() || registered.status() === 409,
`тестовый пользователь ${username} не зарегистрирован: ${await registered.text()}`,
).toBeTruthy();
login = await postWithRetry(api, '/api/v1/auth/login', credentials);
}
expect(login.ok(), `вход тестового пользователя ${username}: ${await login.text()}`).toBeTruthy();
const payload = (await login.json()) as { user: { id: string } };
await finishOnboarding(api);
return { id: payload.user.id, username, email, password };
}
export interface SignInOptions {
/** Параметры контекста: разрешения, viewport и прочее. */
contextOptions?: Parameters<Browser['newContext']>[0];
/** Хук до создания страницы: сюда вешают init-скрипты (например, патч WebRTC). */
onContext?: (context: BrowserContext) => Promise<void>;
}
/** signIn логинит браузерный контекст и открывает приложение. */
export async function signIn(
browser: Browser,
options: { email: string; password: string; totp?: string },
extra: SignInOptions = {},
): Promise<{ context: BrowserContext; page: Page }> {
const context = await browser.newContext(extra.contextOptions ?? {});
if (extra.onContext !== undefined) {
await extra.onContext(context);
}
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Почта', { exact: true }).fill(options.email);
await page.getByLabel('Пароль', { exact: true }).fill(options.password);
// Лимит частоты входов может сработать на серии прогонов: тогда форма
// показывает ошибку — ждём и пробуем снова со свежим кодом 2FA.
for (let attempt = 1; attempt <= 4; attempt += 1) {
if (options.totp !== undefined) {
await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp));
}
await page.getByRole('button', { name: /Войти/u }).click();
try {
await expect(page).toHaveURL(/\/app/u, { timeout: 25_000 });
return { context, page };
} catch (error) {
if (attempt === 4) {
throw error;
}
await page.waitForTimeout(8_000);
}
}
throw new Error('вход не удался');
}
/**
* dropStaleGuilds удаляет тестовые серверы прошлых запусков: если прогон упал
* до уборки, они остаются в рейле и мешают выбрать нужный сервер по имени.
*/
export async function dropStaleGuilds(api: APIRequestContext, prefix: string): Promise<void> {
const response = await api.get('/api/v1/users/@me/guilds');
if (!response.ok()) {
return;
}
const { guilds } = (await response.json()) as { guilds: { id: string; name: string }[] };
for (const guild of guilds) {
if (guild.name.startsWith(prefix)) {
await api.delete(`/api/v1/guilds/${guild.id}`);
}
}
}
/** createGuild создаёт сервер от имени владельца и возвращает его id. */
export async function createGuild(
api: APIRequestContext,
name: string,
): Promise<{ id: string; name: string }> {
const response = await api.post('/api/v1/guilds', { data: { name } });
expect(response.ok(), `создание сервера: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { guild: { id: string } };
return { id: payload.guild.id, name };
}
/** createInvite создаёт приглашение в сервер и возвращает код. */
export async function createInvite(api: APIRequestContext, guildId: string): Promise<string> {
const response = await api.post(`/api/v1/guilds/${guildId}/invites`, {
data: { max_uses: 0, max_age_seconds: 3600 },
});
expect(response.ok(), `создание приглашения: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { invite: { code: string } };
return payload.invite.code;
}
/** joinGuild вступает в сервер по коду приглашения. */
export async function joinGuild(api: APIRequestContext, code: string): Promise<void> {
const response = await postWithRetry(api, `/api/v1/invites/${code}`, {});
expect(response.ok(), `принятие приглашения: ${await response.text()}`).toBeTruthy();
}
/**
* trackLoads считает полные загрузки документа: SPA-переходы их не создают,
* поэтому нулевой счётчик после подготовки доказывает, что ни один шаг
* матрицы 11.6 не потребовал F5.
*/
export function trackLoads(page: Page): { count: () => number; reset: () => void } {
let loads = 0;
page.on('load', () => {
loads += 1;
});
return { count: () => loads, reset: () => (loads = 0) };
}
/** openGuild открывает сервер в сайдбаре. */
export async function openGuild(page: Page, guildName: string): Promise<void> {
await page.getByRole('link', { name: `Открыть сервер ${guildName}`, exact: true }).click();
}
/** openChannel открывает комнату сервера по имени. */
export async function openChannel(page: Page, channelName: string): Promise<void> {
await page.getByRole('link', { name: new RegExp(`Открыть комнату ${channelName}`, 'u') }).click();
}
/**
* pngBytes собирает PNG заданного размера и цвета: сервер проверяет реальный
* формат картинки, поэтому подделкой заголовков не обойтись.
*/
export function pngBytes(width: number, height: number, rgb: [number, number, number]): Buffer {
const raw = Buffer.alloc((width * 3 + 1) * height);
for (let y = 0; y < height; y += 1) {
const rowStart = y * (width * 3 + 1);
raw[rowStart] = 0;
for (let x = 0; x < width; x += 1) {
const at = rowStart + 1 + x * 3;
raw[at] = rgb[0];
raw[at + 1] = rgb[1];
raw[at + 2] = rgb[2];
}
}
const chunk = (type: string, data: Buffer): Buffer => {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([length, body, crc]);
};
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 2;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', header),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
function crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
/* -------------------------------------------------------------------------
* Общие помощники живых спеков (§11.5, §11.6): сессии, лимиты частоты,
* подготовка сервера и уборка. Спеки пунктов 1–13 держат свои копии — здесь
* то, чем пользуются спеки инстанс-админа, личных серверов и шапки сервера.
* ---------------------------------------------------------------------- */
/** Базовый адрес стенда: тот же, что у Playwright (GLCHAT_URL). */
export function standUrl(): string {
return process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
}
/** Префикс версионированного API: помощники ходят теми же ручками, что клиент. */
const API = '/api/v1';
/** Сохранённое состояние сессии: передаётся в `apiAs` и в браузер. */
export type StorageState = Awaited<ReturnType<APIRequestContext['storageState']>>;
/** Фабрика REST-контекстов: сюда приходит фикстура `playwright` из теста. */
export interface Requester {
request: {
newContext(options: {
baseURL: string;
storageState: StorageState;
}): Promise<APIRequestContext>;
};
}
/** Логин пробного аккаунта: username раскрывается в `username@gl.mhspx.su`. */
export interface ProbeLogin {
username: string;
password: string;
}
/** Аккаунт стенда: профиль, сессия теста и отдельная сессия браузера. */
export interface Account extends Probe {
displayName: string;
/** Сессия для REST-действий теста. */
state: StorageState;
/**
* Отдельная сессия для браузера: лимит API — 120 запросов в минуту на
* сессию (AGENT.md 8.6), общая сессия тормозила бы сам интерфейс.
*/
browserState: StorageState;
}
/**
* repeat429 повторяет запрос при срабатывании лимита частоты: сервер отвечает
* `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты,
* которая как раз и обязана работать (AGENT.md 8.6).
*/
export 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 ждём окно
* целиком и повторяем, а не падаем всем файлом.
*/
export async function ensureProbeWithWait(
api: APIRequestContext,
login: ProbeLogin,
): 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}`);
}
/**
* freshState входит заново и отдаёт состояние новой сессии аккаунта: браузер
* получает её отдельно от REST, иначе обе стороны делят один лимит запросов.
*/
export async function freshState(api: APIRequestContext, login: ProbeLogin): Promise<StorageState> {
const email = `${login.username}@gl.mhspx.su`;
let response = await repeat429(() =>
api.post(`${API}/auth/login`, { data: { email, password: login.password } }),
);
if (!response.ok()) {
// Лимит входов (5 в минуту на IP) исчерпан: ждём окно и пробуем ещё раз.
await new Promise((resolve) => setTimeout(resolve, 65_000));
response = await repeat429(() =>
api.post(`${API}/auth/login`, { data: { email, password: login.password } }),
);
}
expect(response.ok(), `повторный вход для браузера: ${await response.text()}`).toBeTruthy();
return api.storageState();
}
/**
* freshAdminState входит администратором заново и отдаёт состояние новой
* сессии для браузера: вход администратора требует кода 2FA (AGENT.md 7.19).
*/
export async function freshAdminState(api: APIRequestContext): Promise<StorageState> {
const admin = adminCredentials();
const login = (): Promise<APIResponse> =>
api.post(`${API}/auth/login`, {
data: { email: admin.email, password: admin.password, totp_code: totp(admin.totp) },
});
let response = await repeat429(login);
if (!response.ok()) {
// Лимит входов (5 в минуту на IP) исчерпан: ждём окно и пробуем ещё раз.
await new Promise((resolve) => setTimeout(resolve, 65_000));
response = await repeat429(login);
}
expect(response.ok(), `вход администратора для браузера: ${await response.text()}`).toBeTruthy();
return api.storageState();
}
/** displayName читает отображаемое имя аккаунта: его показывает интерфейс. */
export 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;
}
/** apiAs создаёт REST-контекст из сохранённой сессии: вход не тратится. */
export function apiAs(
playwright: Requester,
state: StorageState,
baseURL = standUrl(),
): Promise<APIRequestContext> {
return playwright.request.newContext({ baseURL, storageState: state });
}
/** stepUp подтверждает личность: чувствительные ручки требуют свежий step-up. */
export 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();
}
/**
* stepUpAsAdmin подтверждает личность администратора инстанса: у него включена
* обязательная 2FA, поэтому одной парольной проверки серверу мало (AGENT.md 7.19).
*/
export async function stepUpAsAdmin(api: APIRequestContext): Promise<void> {
const admin = adminCredentials();
const response = await repeat429(() =>
api.post(`${API}/auth/step-up`, {
data: { password: admin.password, totp_code: totp(admin.totp) },
}),
);
expect(response.ok(), `step-up администратора: ${await response.text()}`).toBeTruthy();
}
/**
* openSession открывает браузерный контекст из подготовленной сессии: вход
* выполняется один раз на файл, поэтому прогон не упирается в лимит входов.
*/
export async function openSession(browser: Browser, account: Account): Promise<Session> {
return sessionFrom(browser, account.browserState);
}
/** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */
export async function sessionFrom(browser: Browser, state: StorageState): Promise<Session> {
const context = await browser.newContext({ storageState: state });
const page = await context.newPage();
await page.goto('/app');
await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 });
return { context, page };
}
/**
* openApp открывает приложение на готовой сессии и ждёт оболочку, а не рейку
* серверов: у аккаунта без серверов рейка пуста и потому невидима.
*/
export async function openApp(browser: Browser, state: StorageState): Promise<Session> {
const context = await browser.newContext({ storageState: state });
const page = await context.newPage();
await page.goto('/app');
await expect(page.getByTestId('friends-rail-link')).toBeVisible({ timeout: 30_000 });
return { context, page };
}
/** Открытая браузерная сессия: контекст и страница приложения. */
export interface Session {
context: BrowserContext;
page: Page;
}
/** Комната сервера: id, имя и тип — минимум для подготовки и проверок. */
export interface Channel {
id: string;
name: string;
type: string;
description?: string;
}
/** Роль сервера: id, имя, права и позиция в иерархии. */
export interface Role {
id: string;
name: string;
color: number;
position: number;
permissions: string;
is_default: boolean;
}
/** Сервер, подготовленный для теста: id, имя и первая текстовая комната. */
export interface GuildFixture {
guildId: string;
guildName: string;
textChannelId: string;
textChannelName: string;
everyoneRoleId: string;
}
/**
* prepareGuild создаёт сервер от имени владельца и вводит в него наблюдателя:
* вступление идёт через временную публичность, чтобы не тратить суточную квоту
* приглашений (10 на пользователя, AGENT.md 8.6).
*/
export async function prepareGuild(
ownerApi: APIRequestContext,
joinerApi: APIRequestContext,
name: string,
): Promise<GuildFixture> {
const guild = await createGuild(ownerApi, name);
const channels = await listChannels(ownerApi, guild.id);
const text = channels.find((channel) => channel.type === 'text');
expect(text, 'у нового сервера есть текстовая комната').toBeTruthy();
const roles = await listRoles(ownerApi, guild.id);
const everyone = roles.find((role) => role.is_default);
expect(everyone, 'у сервера есть роль по умолчанию').toBeTruthy();
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();
return {
guildId: guild.id,
guildName: guild.name,
textChannelId: text?.id ?? '',
textChannelName: text?.name ?? '',
everyoneRoleId: everyone?.id ?? '',
};
}
/** cleanupGuild удаляет тестовый сервер: мусор на стенде не остаётся. */
export async function cleanupGuild(
api: APIRequestContext,
fixture: GuildFixture | null,
password: string,
): Promise<void> {
if (fixture === null) {
return;
}
await stepUp(api, password).catch(() => undefined);
await repeat429(() => api.delete(`${API}/guilds/${fixture.guildId}`)).catch(() => undefined);
}
/** listChannels читает комнаты сервера. */
export 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;
}
/** createChannel создаёт комнату и возвращает её описание. */
export 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;
}
/** patchChannel меняет комнату: имя, описание, slowmode, лимит участников. */
export async function patchChannel(
api: APIRequestContext,
guildId: string,
channelId: string,
body: Record<string, unknown>,
): Promise<Channel> {
const response = await repeat429(() =>
api.patch(`${API}/guilds/${guildId}/channels/${channelId}`, { data: body }),
);
expect(response.ok(), `правка комнаты: ${await response.text()}`).toBeTruthy();
return ((await response.json()) as { channel: Channel }).channel;
}
/** listRoles читает роли сервера. */
export 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;
}
/** createRole создаёт роль сервера: имя, права и позиция в иерархии. */
export 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;
}
/** assignRole выдаёт роль участнику сервера. */
export 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();
}
/** uploadSound загружает звук саундборда или звуковой палитры сервера. */
export 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;
}
/** wavBytes собирает корректный WAV с тишиной: сервер проверяет тип файла. */
export 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]);
}
/** postMessage отправляет сообщение и возвращает его id. */
export 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;
}
/** openDmChannel открывает личную беседу с пользователем и отдаёт её id. */
export async function openDmChannel(api: APIRequestContext, recipientId: string): Promise<string> {
const response = await repeat429(() =>
api.post(`${API}/users/@me/channels`, { data: { recipient_id: recipientId } }),
);
expect(response.ok(), `личная беседа: ${await response.text()}`).toBeTruthy();
return ((await response.json()) as { channel: { id: string } }).channel.id;
}
/** setBlocked ставит или снимает блокировку пользователя (AGENT.md 7.2). */
export async function setBlocked(
api: APIRequestContext,
userId: string,
blocked: boolean,
): Promise<APIResponse> {
return repeat429(() =>
blocked
? api.put(`${API}/users/@me/blocks/${userId}`)
: api.delete(`${API}/users/@me/blocks/${userId}`),
);
}
/** removeFriendship снимает связь с пользователем: заявку, дружбу или блокировку. */
export async function removeFriendship(api: APIRequestContext, userId: string): Promise<void> {
await repeat429(() => api.delete(`${API}/users/@me/relationships/${userId}`)).catch(
() => undefined,
);
}
/**
* ensureFriends доводит пару до состояния «друзья»: если связи нет — отправляет
* заявку и принимает её. Повторный вызов на существующей дружбе ничего не делает.
*/
export async function ensureFriends(
requesterApi: APIRequestContext,
accepterApi: APIRequestContext,
requesterId: string,
accepterId: string,
): Promise<void> {
const list = await repeat429(() => requesterApi.get(`${API}/users/@me/relationships`));
if (list.ok()) {
const payload = (await list.json()) as { friends: { user_id: string }[] };
if (payload.friends.some((friend) => friend.user_id === accepterId)) {
return;
}
}
const request = await repeat429(() =>
requesterApi.post(`${API}/users/@me/relationships`, { data: { user_id: accepterId } }),
);
expect(request.ok(), `заявка в друзья: ${await request.text()}`).toBeTruthy();
const accept = await repeat429(() =>
accepterApi.post(`${API}/users/@me/relationships/${requesterId}/accept`),
);
expect(accept.ok(), `принятие заявки: ${await accept.text()}`).toBeTruthy();
}
/** openWorkspace открывает сервер и текстовую комнату в браузере. */
export 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 });
}