12dc41ee7c
Кнопка «Войти по ключу доступа» тоже подходит под /Войти/u, поэтому Playwright в strict mode находил два элемента и падал на входе — от этого зависели все спеки, которые логинятся через форму (voice, realtime-social и другие), когда панель passkeys успевала отрисоваться.
904 lines
39 KiB
TypeScript
904 lines
39 KiB
TypeScript
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<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));
|
||
}
|
||
// Точное имя: кнопка «Войти по ключу доступа» тоже подходит под /Войти/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<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 });
|
||
}
|
||
|
||
/** Результат входа в голосовую комнату: подключились или почему нет. */
|
||
export interface VoiceJoinResult {
|
||
ok: boolean;
|
||
/** Причина для отчёта: что именно не поднялось. */
|
||
reason: string;
|
||
}
|
||
|
||
/**
|
||
* voiceJoinApiHealth проверяет серверную часть входа в голос: выдаёт ли инстанс
|
||
* токен LiveKit. Нужна, чтобы отличить сеть стенда (ICE не поднимается) от
|
||
* дефекта продукта (ручка входа не работает).
|
||
*
|
||
* Проба ограничена по времени и по числу попыток: у сессии теста лимит
|
||
* 120 запросов в минуту (AGENT.md 8.6), и «ждать окно» здесь нельзя — иначе
|
||
* проверка не успевает до таймаута теста. Ответ 429 считается признаком живого
|
||
* сервера: лимит частоты к медиапути отношения не имеет.
|
||
*/
|
||
async function voiceJoinApiHealth(
|
||
api: APIRequestContext,
|
||
channelId: string,
|
||
): Promise<VoiceJoinResult> {
|
||
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<VoiceJoinResult> {
|
||
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<void> {
|
||
const result = await joinVoiceIfPossible(page, api, channelId, channelName);
|
||
if (!result.ok) {
|
||
test.skip(true, result.reason);
|
||
}
|
||
}
|