import { createHmac } from 'node:crypto'; import { deflateSync } from 'node:zlib'; import { expect, test, 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, attempts = 8, ): Promise>> { let response = await api.post(path, { data }); for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { // Пауза лежит в конверте ошибки (§8.5: {"error":{"retry_after_ms":…}}), а не // в корне тела: без `error` хелпер ждал фиксированную секунду и сдавался // раньше, чем сервер разрешал повтор — на свежем инстансе регистрация // пробных аккаунтов из-за этого падала (VPS, 27.09.2026). const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number; error?: { retry_after_ms?: number }; }; const wait = Math.min( Math.max(payload.error?.retry_after_ms ?? 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 { 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 { 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[0]; /** Хук до создания страницы: сюда вешают init-скрипты (например, патч WebRTC). */ onContext?: (context: BrowserContext) => Promise; } /** 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)); } // Точное имя: кнопка «Войти по ключу доступа» тоже подходит под /Войти/u, // и без exact локатор ловил бы два элемента (strict mode). await page.getByRole('button', { name: 'Войти', exact: true }).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 { 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 { 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 { 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 { await page.getByRole('link', { name: `Открыть сервер ${guildName}`, exact: true }).click(); } /** openChannel открывает комнату сервера по имени. */ export async function openChannel(page: Page, channelName: string): Promise { 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>; /** Фабрика REST-контекстов: сюда приходит фикстура `playwright` из теста. */ export interface Requester { request: { newContext(options: { baseURL: string; storageState: StorageState; }): Promise; }; } /** Логин пробного аккаунта: 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, attempts = 20, ): Promise { let response = await request(); for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number; error?: { retry_after_ms?: number }; }; // Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту. // Пауза лежит в конверте ошибки (§8.5), поэтому читаем и `error`. const wait = Math.min( Math.max(payload.error?.retry_after_ms ?? 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 { 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 { 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 { const admin = adminCredentials(); const login = (): Promise => 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 { 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 { return playwright.request.newContext({ baseURL, storageState: state }); } /** stepUp подтверждает личность: чувствительные ручки требуют свежий step-up. */ export async function stepUp(api: APIRequestContext, password: string): Promise { const response = await repeat429(() => api.post(`${API}/auth/step-up`, { data: { password } })); expect(response.ok(), `step-up: ${await response.text()}`).toBeTruthy(); } /** * stepUpAsAdmin подтверждает личность администратора инстанса: у него включена * обязательная 2FA, поэтому одной парольной проверки серверу мало (AGENT.md 7.19). */ export async function stepUpAsAdmin(api: APIRequestContext): Promise { 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 { return sessionFrom(browser, account.browserState); } /** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */ export async function sessionFrom(browser: Browser, state: StorageState): Promise { const context = await browser.newContext({ storageState: state }); const page = await context.newPage(); await page.goto('/app'); await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 }); return { context, page }; } /** * openApp открывает приложение на готовой сессии и ждёт оболочку, а не рейку * серверов: у аккаунта без серверов рейка пуста и потому невидима. */ export async function openApp(browser: Browser, state: StorageState): Promise { const context = await browser.newContext({ storageState: state }); const page = await context.newPage(); await page.goto('/app'); await expect(page.getByTestId('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 { 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 { 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 { 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, ): Promise { const response = await repeat429(() => api.post(`${API}/guilds/${guildId}/channels`, { data: body }), ); expect(response.ok(), `создание комнаты: ${await response.text()}`).toBeTruthy(); return ((await response.json()) as { channel: Channel }).channel; } /** patchChannel меняет комнату: имя, описание, slowmode, лимит участников. */ export async function patchChannel( api: APIRequestContext, guildId: string, channelId: string, body: Record, ): Promise { 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 { 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, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }); } /** Результат входа в голосовую комнату: подключились или почему нет. */ export interface VoiceJoinResult { ok: boolean; /** Причина для отчёта: что именно не поднялось. */ reason: string; } /** * voiceJoinApiHealth проверяет серверную часть входа в голос: выдаёт ли инстанс * токен LiveKit. Нужна, чтобы отличить сеть стенда (ICE не поднимается) от * дефекта продукта (ручка входа не работает). * * Проба ограничена по времени и по числу попыток: у сессии теста лимит * 120 запросов в минуту (AGENT.md 8.6), и «ждать окно» здесь нельзя — иначе * проверка не успевает до таймаута теста. Ответ 429 считается признаком живого * сервера: лимит частоты к медиапути отношения не имеет. */ async function voiceJoinApiHealth( api: APIRequestContext, channelId: string, ): Promise { let joined: APIResponse; try { joined = await repeat429( () => api.post(`${API}/channels/${channelId}/voice/join`, { timeout: 15_000 }), 3, ); } catch (error) { return { ok: false, reason: `проба входа в голос не ответила: ${String(error)}` }; } if (joined.status() === 429) { return { ok: true, reason: 'ручка входа в голос жива (ответила лимитом частоты)' }; } if (!joined.ok()) { return { ok: false, reason: `POST /channels/${channelId}/voice/join → ${String(joined.status())}: ${await joined.text()}`, }; } const payload = (await joined.json()) as { token?: string; url?: string }; // Вход в голос записал состояние — выходим, чтобы не оставлять сессию висеть. await repeat429( () => api.post(`${API}/channels/${channelId}/voice/leave`, { timeout: 15_000 }), 2, ).catch(() => undefined); if (payload.token === undefined || payload.token === '' || !payload.url) { return { ok: false, reason: 'сервер вернул пустой токен или адрес LiveKit' }; } return { ok: true, reason: 'серверная часть входа в голос работает' }; } /** * joinVoiceIfPossible входит в голосовую комнату так, как это делает * пользователь: кнопка входа исчезает после подключения. Подключение к SFU * изредка срывается с первого раза, поэтому попыток три — но если клиент уже * показал ошибку, повторять нечего и мы выходим сразу. * * Медиапуть LiveKit — внешняя зависимость (ICE через TURN на VPS). Когда сеть * его не пускает, а серверная часть входа жива, это блокер среды: тест обязан * скипнуться с точной причиной, а не падать (PROGRESS.md, «Блокер среды: * медиапуть LiveKit (ICE)»). Если недоступна и серверная часть — это дефект * продукта, и помощник бросает исключение. */ export async function joinVoiceIfPossible( page: Page, api: APIRequestContext, channelId: string, channelName: string, ): Promise { if (!page.url().includes(channelId)) { // Имя комнаты сравниваем точно: иначе «Голос» совпадёт с «Голос 2». await page.getByRole('link', { name: `Открыть комнату ${channelName}`, exact: true }).click(); } const button = page.getByTestId('voice-join'); await button.waitFor({ state: 'visible', timeout: 20_000 }); for (let attempt = 1; attempt <= 3; attempt += 1) { await button.click({ force: true, timeout: 10_000 }).catch(() => undefined); try { // На живом медиапути подключение занимает секунды; 25 секунд — запас на // медленную машину, и он же ограничивает ожидание при мёртвом ICE. await expect(button).toBeHidden({ timeout: 25_000 }); return { ok: true, reason: '' }; } catch { // Клиент показал ошибку подключения — ждать и повторять нечего. const failed = await page .getByRole('alert') .first() .isVisible() .catch(() => false); if (failed) { break; } await page.waitForTimeout(2_000); } } const health = await voiceJoinApiHealth(api, channelId); // Читаем подпись с ограничением: без него locator ждёт появления элемента // до таймаута теста, и проверка висит вместо скипа. const alert = await page .getByRole('alert') .first() .textContent({ timeout: 5_000 }) .catch(() => null); const shown = alert === null || alert.trim() === '' ? '' : `, клиент показал «${alert.trim()}»`; if (!health.ok) { throw new Error(`голосовая комната недоступна: ${health.reason}${shown}`); } return { ok: false, reason: `медиапуть LiveKit не поднялся (ICE), ${health.reason}${shown}`, }; } /** * joinVoiceOrSkip входит в голосовую комнату и скипает проверку с точной * причиной, если подняться не удалось по вине среды (ICE), а серверная часть * входа работает. Дефект продукта по-прежнему валит тест. */ export async function joinVoiceOrSkip( page: Page, api: APIRequestContext, channelId: string, channelName: string, ): Promise { const result = await joinVoiceIfPossible(page, api, channelId, channelName); if (!result.ok) { test.skip(true, result.reason); } }