Files
glchat/web/e2e/realtime-social.spec.ts
T
grendervill ee334a77bc test(web): голосовые e2e скипаются при недоступном медиапути
Пункты 3, 10 и 11 матрицы и тест саундборда падали не на продукте, а на
инфраструктуре: ICE через TURN на VPS не поднимается (STUN отвечает, но все
ICE-пары `failed` с `requestsReceived: 0`), и проверка трижды ждала кнопку
входа, после чего падал весь прогон.

В support.ts добавлен общий помощник joinVoiceIfPossible/joinVoiceOrSkip:
- три попытки, как раньше, но при уже показанной клиентом ошибке повтор не
  делается, а ожидание ограничено 25 секундами;
- при неудаче проверяется серверная часть входа (POST .../voice/join →
  токен LiveKit): живой сервер при мёртвом ICE — блокер среды, проверка
  скипается с точной причиной; нерабочая ручка — дефект продукта, тест
  падает. Проба ограничена по времени, а ответ 429 считается признаком
  живого сервера (лимит частоты к медиапути отношения не имеет);
- п. 11 при недоступном голосе не отменяется: голосовой мьют пропускается
  с аннотацией и предупреждением в лог, а тайм-аут, кик, возврат сервера в
  рейку и бан по-прежнему проверяются.

Три локальные копии joinVoice заменены одним помощником.

Прогоны на стенде (образ с HEAD): матрица 18 passed / 0 failed / 3 skipped,
§11.5 — 8 passed, два прогона подряд совпали; без правки было 3 failed.
2026-09-26 14:15:53 +03:00

1086 lines
52 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
expect,
test,
type APIRequestContext,
type APIResponse,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
import {
createGuild,
dropStaleGuilds,
ensureProbe,
joinVoiceIfPossible,
joinVoiceOrSkip,
openChannel,
openGuild,
postWithRetry,
trackLoads,
type Probe,
} from './support';
/**
* Realtime-матрица AGENT.md 11.6, пункты 8–13 (сообщения, непрочитанные,
* presence/typing/голос, модерация, два устройства, RESUME): владелец A
* действует по REST, браузер B видит результат через Gateway — без F5 и
* повторного входа. Запуск только по флагу, тест требует стенда:
*
* GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \
* PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \
* npx playwright test e2e/realtime-social.spec.ts
*/
const enabled = process.env.GLCHAT_E2E_REALTIME === '1';
const BASE_URL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
const API = '/api/v1';
/** Префикс имени тестового сервера: по нему убираются серверы прошлых прогонов. */
const GUILD_PREFIX = 'Realtime E2E ';
/** Постоянные пробные аккаунты: A — владелец сервера, B — второй браузер. */
const OWNER_LOGIN = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' };
const MEMBER_LOGIN = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' };
/** Второй пароль пробного участника: тест п.12 возвращает исходный. */
const MEMBER_TEMP_PASSWORD = 'Rt-Probe-Member-Temp-9x';
type StorageState = Awaited<ReturnType<APIRequestContext['storageState']>>;
/** Открытая браузерная сессия: контекст и страница приложения. */
interface Session {
context: BrowserContext;
page: Page;
}
/** Фабрика REST-контекстов: сюда приходит фикстура `playwright` из теста. */
interface Requester {
request: {
newContext(options: {
baseURL: string;
storageState: StorageState;
}): Promise<APIRequestContext>;
};
}
/** Аккаунт стенда: профиль пробного пользователя и сохранённая сессия REST. */
interface Account extends Probe {
/** Отображаемое имя: именно его показывает лента и панель участников. */
displayName: string;
/** Сессия для REST-действий теста. */
state: StorageState;
/**
* Отдельная сессия для браузера: лимит API — 120 запросов в минуту на
* сессию (AGENT.md 8.6), и общая сессия с тестом тормозила бы сам интерфейс.
*/
browserState: StorageState;
}
interface Channel {
id: string;
name: string;
type: string;
}
interface GuildFixture {
guildId: string;
guildName: string;
textChannelId: string;
textChannelName: string;
secondChannelId: string;
secondChannelName: string;
voiceChannelId: string;
voiceChannelName: string;
/** Вторая голосовая комната: создана заранее, чтобы пришла в READY. */
secondVoiceChannelId: string;
secondVoiceChannelName: string;
}
let owner: Account;
let member: Account;
test.describe('realtime: пункты 8–13 (чат, состояния, модерация, RESUME)', () => {
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_REALTIME=1');
/**
* Постоянные аккаунты входят один раз на файл: состояние сессий
* переиспользуется тестами, поэтому прогон не упирается в лимит входов
* (5 в минуту на IP), а тесты остаются независимыми.
*/
// Лимит API — 120 запросов в минуту на сессию (AGENT.md 8.6): короткая пауза
// между тестами даёт ведру лимита наполниться, иначе интерфейс получает 429.
test.afterEach(async () => {
await new Promise((resolve) => setTimeout(resolve, 4_000));
});
test.beforeAll(async ({ playwright }) => {
const ownerApi = await playwright.request.newContext({ baseURL: BASE_URL });
const memberApi = await playwright.request.newContext({ baseURL: BASE_URL });
try {
// Лимит входов — 5 в минуту на IP (AGENT.md 8.6): входы разносим по времени.
const ownerProbe = await ensureProbeWithWait(ownerApi, OWNER_LOGIN);
await new Promise((resolve) => setTimeout(resolve, 2_000));
const memberProbe = await ensureProbeWithWait(memberApi, MEMBER_LOGIN);
await new Promise((resolve) => setTimeout(resolve, 2_000));
const ownerName = await displayName(ownerApi);
const memberName = await displayName(memberApi);
// Уборка серверов прошлых прогонов: их удаление требует свежий step-up.
await stepUp(ownerApi, OWNER_LOGIN.password);
await dropStaleGuilds(ownerApi, GUILD_PREFIX);
// У участника свои серверы (личные серверы тестов) — тоже убираем.
await stepUp(memberApi, MEMBER_LOGIN.password);
await dropStaleGuilds(memberApi, GUILD_PREFIX);
owner = {
...ownerProbe,
displayName: ownerName,
state: await ownerApi.storageState(),
browserState: await freshState(ownerApi, OWNER_LOGIN),
};
member = {
...memberProbe,
displayName: memberName,
state: await memberApi.storageState(),
browserState: await freshState(memberApi, MEMBER_LOGIN),
};
} finally {
await ownerApi.dispose();
await memberApi.dispose();
}
});
test('8. Сообщения: отправка, правка, реакции, пины и удаление — лента без перезагрузки', async ({
browser,
playwright,
}) => {
const ownerApi = await apiAs(playwright, owner.state);
const memberApi = await apiAs(playwright, member.state);
let session: Session | null = null;
let fixture: GuildFixture | null = null;
try {
fixture = await prepareGuild(ownerApi, memberApi);
session = await openSession(browser, member);
const page = session.page;
await openWorkspace(page, fixture);
const loads = trackLoads(page);
loads.reset();
const list = page.getByTestId('message-list');
// Отправка: сообщение владельца появляется в ленте B.
const text = `Привет из realtime ${Date.now()}`;
const messageId = await postMessage(ownerApi, fixture.textChannelId, text);
const article = list.locator(`article[data-testid="message-${messageId}"]`);
await expect(article).toContainText(text, { timeout: 20_000 });
// Правка: текст и пометка «(изменено)» приходят событием MESSAGE_UPDATE.
const edited = await repeat429(() =>
ownerApi.patch(`${API}/channels/${fixture.textChannelId}/messages/${messageId}`, {
data: { content: `${text} (правка)` },
}),
);
expect(edited.ok(), `правка сообщения: ${await edited.text()}`).toBeTruthy();
await expect(article).toContainText('(правка)', { timeout: 20_000 });
await expect(article).toContainText('(изменено)', { timeout: 20_000 });
// Реакция: чип реакции появляется у B.
const reaction = await repeat429(() =>
ownerApi.put(
`${API}/channels/${fixture.textChannelId}/messages/${messageId}/reactions/${encodeURIComponent('👍')}`,
),
);
expect(reaction.ok(), `реакция: ${await reaction.text()}`).toBeTruthy();
await expect(article.getByRole('button', { name: 'Реакция 👍: 1' })).toBeVisible({
timeout: 20_000,
});
// Пин: счётчик закреплённых в шапке комнаты обновляется.
const pinned = await repeat429(() =>
ownerApi.put(`${API}/channels/${fixture.textChannelId}/pins/${messageId}`),
);
expect(pinned.ok(), `закрепление: ${await pinned.text()}`).toBeTruthy();
await expect(
page.getByTestId('channel-header').getByRole('button', { name: 'Закреплённые сообщения' }),
).toContainText('1', { timeout: 20_000 });
// Снятие пина и удаление сообщения.
const unpinned = await repeat429(() =>
ownerApi.delete(`${API}/channels/${fixture.textChannelId}/pins/${messageId}`),
);
expect(unpinned.ok(), `снятие закрепления: ${await unpinned.text()}`).toBeTruthy();
await expect(
page.getByTestId('channel-header').getByRole('button', { name: 'Закреплённые сообщения' }),
).not.toContainText('1', { timeout: 20_000 });
const removed = await repeat429(() =>
ownerApi.delete(`${API}/channels/${fixture.textChannelId}/messages/${messageId}`),
);
expect(removed.ok(), `удаление сообщения: ${await removed.text()}`).toBeTruthy();
await expect(article).toHaveCount(0, { timeout: 20_000 });
// Пункт 14: ни один шаг не потребовал перезагрузки страницы.
expect(loads.count(), 'лента обновлялась без полной загрузки документа').toBe(0);
} finally {
await session?.context.close();
await cleanup(ownerApi, fixture);
await ownerApi.dispose();
await memberApi.dispose();
}
});
test('9. Непрочитанные и упоминания синхронизируются между устройствами пользователя', async ({
browser,
playwright,
}) => {
const ownerApi = await apiAs(playwright, owner.state);
const memberApi = await apiAs(playwright, member.state);
const sessions: Session[] = [];
let fixture: GuildFixture | null = null;
try {
fixture = await prepareGuild(ownerApi, memberApi);
// Два устройства одного пользователя: у каждого своя сессия.
for (let device = 0; device < 2; device += 1) {
sessions.push(await openSession(browser, member));
}
const [first, second] = sessions as [Session, Session];
// Оба устройства открывают общую комнату, чтобы история загрузилась.
for (const device of [first, second]) {
await openWorkspace(device.page, fixture);
}
// Отметка прочтения: клиент ставит её, когда окно активно; здесь она
// выставляется ручкой сервера и рассылается всем сессиям пользователя.
const ack = await repeat429(() =>
memberApi.post(`${API}/channels/${fixture.textChannelId}/ack`, {
data: {},
}),
);
expect(ack.ok(), `отметка прочтения: ${await ack.text()}`).toBeTruthy();
for (const device of [first, second]) {
await openChannel(device.page, fixture.secondChannelName);
await expect(device.page.getByTestId('channel-header')).toContainText(
fixture.secondChannelName,
);
}
const loads = trackLoads(first.page);
loads.reset();
trackLoads(second.page).reset();
// Обычное сообщение: счётчик непрочитанных появляется на обоих
// устройствах — история комнаты у них уже загружена.
await postMessage(ownerApi, fixture.textChannelId, 'Первое сообщение');
const unreadBadge = `channel-unread-${fixture.textChannelId}`;
await expect(first.page.getByTestId(unreadBadge)).toBeVisible({ timeout: 20_000 });
await expect(second.page.getByTestId(unreadBadge)).toBeVisible({ timeout: 20_000 });
// Упоминание: сервер считает mention_count и шлёт READ_STATE_UPDATE всем
// сессиям пользователя; бейдж упоминаний важнее счётчика непрочитанных.
// Отдельного центра уведомлений в клиенте нет (раздел «Уведомления» —
// заглушка), поэтому проверяются бейджи комнат.
await postMessage(
ownerApi,
fixture.textChannelId,
`<@${member.id}> проверка упоминания ${Date.now()}`,
);
const mentionBadge = `channel-mentions-${fixture.textChannelId}`;
await expect(first.page.getByTestId(mentionBadge)).toBeVisible({ timeout: 20_000 });
await expect(second.page.getByTestId(mentionBadge)).toBeVisible({ timeout: 20_000 });
// Первое устройство читает комнату: подтверждение прочтения уходит на
// сервер, тот рассылает READ_STATE_UPDATE — бейджи гаснут и на втором.
await first.page.bringToFront();
await openChannel(first.page, fixture.textChannelName);
await expect(first.page.getByTestId(mentionBadge)).toHaveCount(0, { timeout: 20_000 });
await expect(second.page.getByTestId(mentionBadge)).toHaveCount(0, { timeout: 20_000 });
await expect(second.page.getByTestId(unreadBadge)).toHaveCount(0, { timeout: 20_000 });
expect(loads.count(), 'бейджи менялись без полной загрузки документа').toBe(0);
} finally {
for (const device of sessions) {
await device.context.close();
}
await cleanup(ownerApi, fixture);
await ownerApi.dispose();
await memberApi.dispose();
}
});
test('10. Presence, typing, голосовые состояния и перемещение между комнатами', async ({
browser,
playwright,
}) => {
const ownerApi = await apiAs(playwright, owner.state);
const memberApi = await apiAs(playwright, member.state);
let session: Session | null = null;
let fixture: GuildFixture | null = null;
try {
fixture = await prepareGuild(ownerApi, memberApi);
await makeFriends(ownerApi, memberApi, member.id);
session = await openSession(browser, member);
const page = session.page;
await openWorkspace(page, fixture);
const loads = trackLoads(page);
loads.reset();
// Presence: список друзей свёрнут по умолчанию — раскрываем его и
// проверяем, что смена статуса владельца доходит до друга сразу.
const online = await repeat429(() =>
ownerApi.patch(`${API}/users/@me`, { data: { status: 'online' } }),
);
expect(online.ok(), `статус «в сети»: ${await online.text()}`).toBeTruthy();
await page.getByRole('link', { name: 'Друзья' }).click();
await page.getByTestId('friends-list-toggle').click({ timeout: 15_000 });
const friendStatus = page.getByTestId(`friend-status-${owner.id}`).first();
await expect(friendStatus).toHaveAttribute('data-status', 'online', { timeout: 20_000 });
const status = await repeat429(() =>
ownerApi.patch(`${API}/users/@me`, { data: { status: 'dnd' } }),
);
expect(status.ok(), `смена статуса: ${await status.text()}`).toBeTruthy();
await expect(friendStatus).toHaveAttribute('data-status', 'dnd', { timeout: 20_000 });
// Typing: набор текста владельцем показывает индикатор у B.
await openGuild(page, fixture.guildName);
await openChannel(page, fixture.textChannelName);
const typing = await repeat429(() =>
ownerApi.post(`${API}/channels/${fixture.textChannelId}/typing`),
);
expect(typing.ok(), `typing: ${await typing.text()}`).toBeTruthy();
await expect(page.getByTestId('typing-indicator')).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId('typing-indicator')).toContainText(owner.displayName, {
timeout: 20_000,
});
// Голос: B входит в первую комнату, владелец переносит его во вторую.
// Дальше всё построено на голосовой сессии, поэтому при недоступном
// медиапути проверка скипается с причиной (см. support.ts).
await joinVoiceOrSkip(page, memberApi, fixture.voiceChannelId, fixture.voiceChannelName);
// Вторая комната создана до входа B: CHANNEL_CREATE приходит с
// can_view:false, и созданная на лету комната в сайдбаре не появится.
const moved = await repeat429(() =>
ownerApi.post(`${API}/guilds/${fixture.guildId}/voice-states/${member.id}/move`, {
data: { channel_id: fixture.secondVoiceChannelId },
}),
);
expect(moved.ok(), `перемещение участника: ${await moved.text()}`).toBeTruthy();
await expect(
page
.getByRole('list', {
name: `Участники комнаты «${fixture.secondVoiceChannelName}»`,
})
.getByRole('button', { name: new RegExp(member.displayName, 'u') })
.first(),
).toBeVisible({ timeout: 30_000 });
// Серверный мьют применяется сразу: в списке участников комнаты у B
// появляется пометка модератора, а в самой комнате блокируется микрофон.
await page
.getByRole('link', {
name: `Открыть комнату ${fixture.secondVoiceChannelName}`,
exact: true,
})
.click();
const mute = await repeat429(() =>
ownerApi.patch(`${API}/guilds/${fixture.guildId}/voice-states/${member.id}`, {
data: { server_mute: true },
}),
);
expect(mute.ok(), `серверный мьют: ${await mute.text()}`).toBeTruthy();
await expect(
page
.getByRole('list', {
name: `Участники комнаты «${fixture.secondVoiceChannelName}»`,
})
.getByRole('img', { name: 'Микрофон выключен модератором' })
.first(),
).toBeVisible({ timeout: 30_000 });
await expect(
page.getByTestId(`voice-tile-${member.id}`).getByRole('img', {
name: 'Микрофон выключен модератором',
}),
).toBeVisible({ timeout: 30_000 });
expect(loads.count(), 'состояния менялись без полной загрузки документа').toBe(0);
} finally {
await session?.context.close();
await cleanup(ownerApi, fixture);
await ownerApi.dispose();
await memberApi.dispose();
}
});
test('11. Кик, бан, тайм-аут и мут применяются сразу, списки обновляются', async ({
browser,
playwright,
}) => {
const ownerApi = await apiAs(playwright, owner.state);
const memberApi = await apiAs(playwright, member.state);
const sessions: Session[] = [];
let fixture: GuildFixture | null = null;
let personal: { id: string; name: string } | null = null;
try {
fixture = await prepareGuild(ownerApi, memberApi);
// Личный сервер B: без него после кика рейка пустеет, и клиент
// возвращает удалённую плитку из устаревшего кэша `myGuilds`
// (web/src/pages/app/AppLayout.tsx:112-119, см. отчёт).
personal = await createGuild(memberApi, `${GUILD_PREFIX}Личный ${Date.now()}`);
// B — цель модерации, A-браузер — наблюдатель списка участников.
const memberSession = await openSession(browser, member);
sessions.push(memberSession);
const ownerSession = await openSession(browser, owner);
sessions.push(ownerSession);
await openWorkspace(memberSession.page, fixture);
await openGuild(ownerSession.page, fixture.guildName);
await openServerSettings(ownerSession.page, fixture.guildId);
const memberRow = ownerSession.page
.getByTestId('guild-members')
.locator('li', { hasText: '@rtprobe2' });
await expect(memberRow).toBeVisible({ timeout: 20_000 });
// Серверный мьют отключает микрофон B — но проверить его можно только
// внутри голосовой комнаты. Медиапуть LiveKit — внешняя зависимость:
// когда ICE не поднимается, мьют в голосовой проверить нечем, зато
// остальная модерация ниже (тайм-аут, кик, возврат в рейку, бан) обязана
// выполняться, поэтому голос здесь необязателен, а прогон получает
// аннотацию о сокращённом покрытии. Дефект серверной части входа в голос
// по-прежнему валит тест (support.ts).
const voiceJoin = await joinVoiceIfPossible(
memberSession.page,
memberApi,
fixture.voiceChannelId,
fixture.voiceChannelName,
);
if (voiceJoin.ok) {
const mute = await repeat429(() =>
ownerApi.patch(`${API}/guilds/${fixture.guildId}/voice-states/${member.id}`, {
data: { server_mute: true },
}),
);
expect(mute.ok(), `серверный мьют: ${await mute.text()}`).toBeTruthy();
const mic = memberSession.page
.getByRole('group', { name: /Управление голосом/u })
.getByRole('button')
.first();
await expect(mic).toBeDisabled({ timeout: 30_000 });
} else {
test.info().annotations.push({ type: 'voice-unavailable', description: voiceJoin.reason });
console.warn(`[e2e] пункт 11: голосовой мьют не проверен — ${voiceJoin.reason}`);
}
// Тайм-аут: писать участник больше не может — сервер отвечает отказом.
const until = new Date(Date.now() + 10 * 60_000).toISOString();
const timeout = await repeat429(() =>
ownerApi.patch(`${API}/guilds/${fixture.guildId}/members/${member.id}`, {
data: { timeout_until: until },
}),
);
expect(timeout.ok(), `тайм-аут: ${await timeout.text()}`).toBeTruthy();
const timedOut = await repeat429(() =>
memberApi.post(`${API}/channels/${fixture.textChannelId}/messages`, {
data: { content: 'сообщение во время тайм-аута' },
}),
);
expect(timedOut.ok(), 'тайм-аут запрещает отправку сообщений').toBeFalsy();
expect(timedOut.status(), 'код отказа по тайм-ауту').toBe(403);
// Кик: сервер сообщает участнику, что сервер исчез, а список участников
// у наблюдателя обновляется без перезагрузки.
const kicked = await repeat429(() =>
ownerApi.delete(`${API}/guilds/${fixture.guildId}/members/${member.id}`),
);
expect(kicked.ok(), `кик: ${await kicked.text()}`).toBeTruthy();
await expect(
memberSession.page.getByRole('link', {
name: `Открыть сервер ${fixture.guildName}`,
exact: true,
}),
).toHaveCount(0, { timeout: 20_000 });
await expect(memberRow).toHaveCount(0, { timeout: 20_000 });
// Возврат в сервер: он на время открывается — приглашения тратят
// суточную квоту 10/24ч (AGENT.md 8.6). Участник снова в составе, и
// плитка сервера возвращается в рейку B без перезагрузки: по GUILD_CREATE
// клиент перечитывает список серверов (web/src/stores/gateway.ts,
// addGuildFromRest), а не берёт устаревший кэш myGuilds.
await setPublic(ownerApi, fixture.guildId, true);
const rejoin = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`);
expect(rejoin.ok(), `возврат в сервер: ${await rejoin.text()}`).toBeTruthy();
await expect
.poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 })
.toContain(member.id);
await expect(
memberSession.page.getByRole('link', {
name: `Открыть сервер ${fixture.guildName}`,
exact: true,
}),
).toBeVisible({ timeout: 20_000 });
await setPublic(ownerApi, fixture.guildId, false);
// Бан: участник снова исключён, попал в список банов и не может вернуться
// даже в открытый сервер — сервер отвечает 403 guild.banned.
const banned = await ownerApi.put(`${API}/guilds/${fixture.guildId}/bans/${member.id}`, {
data: { reason: 'realtime e2e' },
});
expect(banned.ok(), `бан: ${await banned.text()}`).toBeTruthy();
await expect
.poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 })
.not.toContain(member.id);
const bans = await ownerApi.get(`${API}/guilds/${fixture.guildId}/bans`);
const banList = ((await bans.json()) as { bans: { user_id: string }[] }).bans;
expect(
banList.map((ban) => ban.user_id),
'забаненный участник в списке банов',
).toContain(member.id);
await setPublic(ownerApi, fixture.guildId, true);
const blocked = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`);
expect(blocked.ok(), 'забаненный не возвращается в открытый сервер').toBeFalsy();
expect(blocked.status(), 'код отказа для забаненного').toBe(403);
} finally {
for (const device of sessions) {
await device.context.close();
}
await dropGuild(memberApi, personal?.id ?? '', MEMBER_LOGIN.password);
await cleanup(ownerApi, fixture);
await ownerApi.dispose();
await memberApi.dispose();
}
});
test('13. После реконнекта RESUME досылает пропущенное, состояние совпадает с сервером', async ({
browser,
playwright,
}) => {
const ownerApi = await apiAs(playwright, owner.state);
const memberApi = await apiAs(playwright, member.state);
let session: Session | null = null;
let fixture: GuildFixture | null = null;
try {
fixture = await prepareGuild(ownerApi, memberApi);
// Отдельная сессия: тест 12 отзывает общие сессии файла (logout-all).
session = await freshSession(
browser,
playwright,
member,
member.password,
captureGatewayFrames,
);
const page = session.page;
await openWorkspace(page, fixture);
const loads = trackLoads(page);
loads.reset();
// Разрыв связи: сокет шлюза закрывается, сеть выключается — клиент
// уходит в переподключение и не может получить события.
await page.evaluate(() => {
const globalWindow = window as unknown as { __sockets?: WebSocket[] };
for (const socket of globalWindow.__sockets ?? []) {
socket.close();
}
});
await session.context.setOffline(true);
const missed: string[] = [];
for (let index = 0; index < 3; index += 1) {
missed.push(
await postMessage(ownerApi, fixture.textChannelId, `Пропущенное сообщение ${index}`),
);
}
await session.context.setOffline(false);
// Догон: все три сообщения появляются в ленте B без перезагрузки.
const list = page.getByTestId('message-list');
for (const id of missed) {
await expect(list.locator(`article[data-testid="message-${id}"]`)).toBeVisible({
timeout: 60_000,
});
}
// Состояние совпадает с сервером: набор сообщений в ленте равен ответу REST.
const server = await repeat429(() =>
ownerApi.get(`${API}/channels/${fixture.textChannelId}/messages?limit=50`),
);
const serverIds = ((await server.json()) as { messages: { id: string }[] }).messages.map(
(message) => message.id,
);
const rendered = await list
.locator('article[data-message-id]')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-message-id') ?? ''));
for (const id of serverIds) {
expect(rendered, `сообщение ${id} есть в ленте после RESUME`).toContain(id);
}
// Клиент действительно возобновил сессию, а не получил READY заново.
const frames = await page.evaluate(
() => (window as unknown as { __frames?: string[] }).__frames ?? [],
);
const sent = await page.evaluate(
() => (window as unknown as { __sent?: string[] }).__sent ?? [],
);
expect(
sent.some((frame) => frame.includes('"op":3')),
'клиент отправил RESUME',
).toBe(true);
expect(
frames.some((frame) => frame.includes('"t":"RESUMED"')),
'сервер подтвердил догон событий',
).toBe(true);
expect(loads.count(), 'догон событий прошёл без полной загрузки документа').toBe(0);
} finally {
await session?.context.close();
await cleanup(ownerApi, fixture);
await ownerApi.dispose();
await memberApi.dispose();
}
});
test('12. Два устройства: смена пароля и выход на всех отражаются на втором', async ({
browser,
playwright,
}) => {
// Тест идёт последним: logout-all и смена пароля отзывают подготовленные
// сессии файла, поэтому остальным тестам они должны остаться живыми.
//
// Оба действия подтверждаются на втором устройстве без перезагрузки —
// сервер шлёт INVALID_SESSION, клиент сразу уходит на экран входа, даже
// когда открыт сервер (AGENT.md 11.6).
const ownerApi = await apiAs(playwright, owner.state);
const memberApi = await apiAs(playwright, member.state);
const sessions: Session[] = [];
let fixture: GuildFixture | null = null;
// Устройство, сменившее пароль: с него возвращаем исходный в finally, чтобы
// падение теста не оставило постоянный пробный аккаунт с временным паролем.
let passwordDevice: Page | null = null;
let passwordChanged = false;
try {
fixture = await prepareGuild(ownerApi, memberApi);
// Две готовые сессии пользователя — два устройства без лишних входов.
const first = await sessionFrom(browser, member.browserState);
const second = await sessionFrom(browser, member.state);
sessions.push(first, second);
await openWorkspace(first.page, fixture);
await openWorkspace(second.page, fixture);
// Выход на всех устройствах: второе устройство теряет сессию сразу.
await logoutAll(first.page);
const afterLogout = await second.page.evaluate(async () => {
const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' });
return response.status;
});
expect(afterLogout, 'сессия второго устройства отозвана').toBe(401);
await expect(second.page).toHaveURL(/\/login/u, { timeout: 30_000 });
// Смена пароля: два новых устройства, пароль меняется через интерфейс.
const third = await freshSession(browser, playwright, member);
const fourth = await freshSession(browser, playwright, member);
sessions.push(third, fourth);
await openWorkspace(third.page, fixture);
await openWorkspace(fourth.page, fixture);
passwordDevice = third.page;
await changePassword(third.page, member.password, MEMBER_TEMP_PASSWORD);
passwordChanged = true;
const revoked = await fourth.page.evaluate(async () => {
const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' });
return response.status;
});
expect(revoked, 'сессия второго устройства отозвана сменой пароля').toBe(401);
await expect(fourth.page).toHaveURL(/\/login/u, { timeout: 30_000 });
} finally {
if (passwordChanged && passwordDevice !== null) {
const restored = await restoreMemberPassword(passwordDevice, member.password);
expect(
restored,
`пароль пробного аккаунта ${MEMBER_LOGIN.username} возвращён (${restored})`,
).toBe(200);
}
for (const device of sessions) {
await device.context.close();
}
await cleanup(ownerApi, fixture);
await ownerApi.dispose();
await memberApi.dispose();
}
});
});
/**
* openSession открывает браузерный контекст из подготовленной сессии: вход
* выполняется один раз на файл, поэтому прогон не упирается в лимит входов
* (5 в минуту на IP, AGENT.md 8.6).
*/
async function openSession(
browser: Browser,
account: Account,
onContext?: (context: BrowserContext) => Promise<void>,
): Promise<Session> {
const context = await browser.newContext({ storageState: account.browserState });
if (onContext !== undefined) {
await onContext(context);
}
const page = await context.newPage();
await page.goto('/app');
await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 });
return { context, page };
}
/**
* freshSession поднимает ещё одну независимую сессию аккаунта (второе
* устройство): вход идёт по REST, чтобы не тратить лимит входов из браузера.
*/
async function freshSession(
browser: Browser,
playwright: Requester,
account: Account,
password = account.password,
onContext?: (context: BrowserContext) => Promise<void>,
): Promise<Session> {
const api = await playwright.request.newContext({ baseURL: BASE_URL });
try {
const login = await postWithRetry(api, `${API}/auth/login`, {
email: account.email,
password,
});
expect(login.ok(), `вход ${account.username}: ${await login.text()}`).toBeTruthy();
const context = await browser.newContext({ storageState: await api.storageState() });
if (onContext !== undefined) {
await onContext(context);
}
const page = await context.newPage();
await page.goto('/app');
await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 });
return { context, page };
} finally {
await api.dispose();
}
}
/** freshState входит заново и отдаёт состояние новой сессии аккаунта. */
async function freshState(
api: APIRequestContext,
login: { username: string; password: string },
): Promise<StorageState> {
const response = await repeat429(() =>
api.post(`${API}/auth/login`, {
data: { email: `${login.username}@gl.mhspx.su`, password: login.password },
}),
);
if (!response.ok()) {
// Лимит входов (5 в минуту на IP) исчерпан: ждём окно и пробуем ещё раз,
// иначе браузер и REST разделят одну сессию и упрутся в лимит запросов.
await new Promise((resolve) => setTimeout(resolve, 65_000));
const retry = await repeat429(() =>
api.post(`${API}/auth/login`, {
data: { email: `${login.username}@gl.mhspx.su`, password: login.password },
}),
);
expect(retry.ok(), `повторный вход для браузера: ${await retry.text()}`).toBeTruthy();
}
return api.storageState();
}
/** displayName читает отображаемое имя аккаунта: его показывает интерфейс. */
async function displayName(api: APIRequestContext): Promise<string> {
const response = await repeat429(() => api.get(`${API}/users/@me`));
expect(response.ok(), `текущий пользователь: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { user: { display_name: string } };
return payload.user.display_name;
}
/**
* repeat429 повторяет запрос при срабатывании лимита частоты: сервер отвечает
* `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты,
* которая как раз и обязана работать (AGENT.md 8.6).
*/
async function repeat429(request: () => Promise<APIResponse>, attempts = 20): Promise<APIResponse> {
let response = await request();
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number };
// Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту.
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 1_000), 30_000);
await new Promise((resolve) => setTimeout(resolve, wait + 250));
response = await request();
}
return response;
}
/**
* ensureProbeWithWait входит в пробный аккаунт, пережидая лимит входов: сервер
* пускает 5 попыток в минуту на IP (AGENT.md 8.6), поэтому при 429 ждём окно
* целиком и повторяем, а не падаем всем файлом.
*/
async function ensureProbeWithWait(
api: APIRequestContext,
login: { username: string; password: string },
): Promise<Probe> {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await ensureProbe(api, login.username, login.password);
} catch {
await new Promise((resolve) => setTimeout(resolve, 65_000));
}
}
throw new Error(`не удалось войти в пробный аккаунт ${login.username}`);
}
/** apiAs создаёт REST-контекст из сохранённой сессии: вход не тратится. */
function apiAs(playwright: Requester, state: StorageState): Promise<APIRequestContext> {
return playwright.request.newContext({ baseURL: BASE_URL, storageState: state });
}
/** stepUp подтверждает личность: чувствительные ручки требуют свежий step-up. */
async function stepUp(api: APIRequestContext, password: string): Promise<void> {
const response = await repeat429(() => api.post(`${API}/auth/step-up`, { data: { password } }));
expect(response.ok(), `step-up: ${await response.text()}`).toBeTruthy();
}
/**
* prepareGuild создаёт сервер от имени владельца, две текстовые и голосовую
* комнаты и приглашает участника.
*/
async function prepareGuild(
ownerApi: APIRequestContext,
memberApi: APIRequestContext,
): Promise<GuildFixture> {
const guildName = `${GUILD_PREFIX}${Date.now()}`;
const guild = await createGuild(ownerApi, guildName);
const channels = await listChannels(ownerApi, guild.id);
const text = channels.find((channel) => channel.type === 'text');
expect(text, 'у нового сервера есть текстовая комната').toBeTruthy();
const voice = await createChannel(ownerApi, guild.id, { name: 'Голос', type: 'voice' });
const secondVoice = await createChannel(ownerApi, guild.id, { name: 'Голос 2', type: 'voice' });
const second = await createChannel(ownerApi, guild.id, { name: 'Вторая', type: 'text' });
// Вступление без приглашения: сервер на время делается открытым, потому что
// квота приглашений — 10 в сутки на пользователя (AGENT.md 8.6).
const opened = await repeat429(() =>
ownerApi.patch(`${API}/guilds/${guild.id}`, { data: { public: true } }),
);
expect(opened.ok(), `открытый сервер: ${await opened.text()}`).toBeTruthy();
const joined = await repeat429(() => memberApi.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,
textChannelId: text?.id ?? '',
textChannelName: text?.name ?? '',
secondChannelId: second.id,
secondChannelName: second.name,
voiceChannelId: voice.id,
voiceChannelName: voice.name,
secondVoiceChannelId: secondVoice.id,
secondVoiceChannelName: secondVoice.name,
};
}
/** dropGuild удаляет сервер по id: мусор на стенде не остаётся. */
async function dropGuild(api: APIRequestContext, guildId: string, password: string): Promise<void> {
if (guildId === '') {
return;
}
await stepUp(api, password).catch(() => undefined);
await api.delete(`${API}/guilds/${guildId}`).catch(() => undefined);
}
/** cleanup удаляет тестовый сервер: мусор на стенде не остаётся. */
async function cleanup(ownerApi: APIRequestContext, fixture: GuildFixture | null): Promise<void> {
if (fixture === null) {
return;
}
await stepUp(ownerApi, OWNER_LOGIN.password).catch(() => undefined);
await repeat429(() => ownerApi.delete(`${API}/guilds/${fixture.guildId}`)).catch(() => undefined);
}
/** openWorkspace открывает сервер и первую текстовую комнату у B. */
async function openWorkspace(page: Page, fixture: GuildFixture): Promise<void> {
await openGuild(page, fixture.guildName);
await openChannel(page, fixture.textChannelName);
await expect(page.getByTestId('guild-header')).toContainText(fixture.guildName, {
timeout: 30_000,
});
// Машина под нагрузкой грузит SPA медленнее: первой отрисовке даём запас.
await expect(page.getByTestId('message-list')).toBeVisible({ timeout: 30_000 });
}
/**
* openServerSettings открывает настройки сервера ссылкой `/settings/servers/…`:
* пункт «Сервер» в списке виден только владельцу, а адрес открывает раздел
* напрямую — права при этом проверяет сам раздел по свежему ответу REST.
*/
async function openServerSettings(page: Page, guildId: string): Promise<void> {
await page.goto(`/settings/servers/${guildId}`);
await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 30_000 });
}
/** changePassword меняет пароль пользователя через настройки безопасности. */
async function changePassword(page: Page, current: string, next: string): Promise<void> {
await page.goto('/settings/account/security');
const content = page.getByTestId('settings-content');
await expect(content).toBeVisible({ timeout: 20_000 });
await content.getByLabel('Текущий пароль', { exact: true }).fill(current);
await content.getByLabel('Новый пароль', { exact: true }).fill(next);
await content.getByLabel('Повторите новый пароль', { exact: true }).fill(next);
await content.getByRole('button', { name: 'Сменить пароль' }).click();
await expect(content.getByRole('status')).toContainText('Пароль изменён', { timeout: 20_000 });
}
/**
* restoreMemberPassword возвращает пробному участнику исходный пароль с
* устройства, которое его сменило. Возврат идёт из finally: если он не удался,
* постоянный аккаунт останется с временным паролем и следующие прогоны файла
* не смогут войти — поэтому тест обязан сообщить об этом явно.
*/
async function restoreMemberPassword(page: Page, password: string): Promise<number> {
return page.evaluate(
async (payload: { current: string; next: string }) => {
const response = await fetch('/api/v1/users/@me/password', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ current_password: payload.current, new_password: payload.next }),
});
return response.status;
},
{ current: MEMBER_TEMP_PASSWORD, next: password },
);
}
/** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */
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 };
}
/** logoutAll выходит на всех устройствах через настройки безопасности. */
async function logoutAll(page: Page): Promise<void> {
await page.goto('/settings/account/security');
const content = page.getByTestId('settings-content');
await expect(content).toBeVisible({ timeout: 20_000 });
// Подтверждение выхода — системный диалог браузера.
page.once('dialog', (dialog) => void dialog.accept());
await content
.getByRole('button', { name: 'Выйти на всех устройствах' })
.click({ timeout: 15_000 });
await expect(page).toHaveURL(/\/login/u, { timeout: 20_000 });
}
/** makeFriends делает владельца и участника друзьями: нужен presence-канал. */
async function makeFriends(
ownerApi: APIRequestContext,
memberApi: APIRequestContext,
memberId: string,
): Promise<void> {
const request = await repeat429(() =>
ownerApi.post(`${API}/users/@me/relationships`, {
data: { user_id: memberId },
}),
);
expect(request.ok(), `заявка в друзья: ${await request.text()}`).toBeTruthy();
const accept = await repeat429(() =>
memberApi.post(`${API}/users/@me/relationships/${owner.id}/accept`),
);
if (accept.ok()) {
return;
}
// Повторный прогон: заявка уже принята, дружба существует.
const list = await repeat429(() => memberApi.get(`${API}/users/@me/relationships`));
const payload = (await list.json()) as { friends: { user_id: string }[] };
expect(
payload.friends.map((friend) => friend.user_id),
`дружба с ${owner.username} установлена`,
).toContain(owner.id);
}
/**
* captureGatewayFrames запоминает кадры шлюза и открытые сокеты: по ним
* проверяется RESUME и разрыв связи.
*/
async function captureGatewayFrames(context: BrowserContext): Promise<void> {
await context.addInitScript(() => {
const Original = window.WebSocket;
const globalWindow = window as unknown as {
__frames?: string[];
__sent?: string[];
__sockets?: WebSocket[];
};
globalWindow.__frames = [];
globalWindow.__sent = [];
globalWindow.__sockets = [];
window.WebSocket = function Patched(...args: ConstructorParameters<typeof WebSocket>) {
const socket = new Original(...args);
globalWindow.__sockets?.push(socket);
socket.addEventListener('message', (event: MessageEvent) => {
const text = String(event.data);
if (text.includes('RESUMED') || text.includes('READY')) {
globalWindow.__frames?.push(text.slice(0, 400));
}
});
// Исходящие кадры: по ним видно, что клиент отправил именно RESUME.
const send = socket.send.bind(socket);
socket.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => {
if (typeof data === 'string') {
globalWindow.__sent?.push(data.slice(0, 200));
}
send(data);
};
return socket;
} as unknown as typeof WebSocket;
window.WebSocket.prototype = Original.prototype;
});
}
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;
}
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;
}
/** guildMemberIds возвращает состав участников сервера. */
async function guildMemberIds(api: APIRequestContext, guildId: string): Promise<string[]> {
const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/members`));
expect(response.ok(), `участники сервера: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { members: { user_id: string }[] };
return payload.members.map((item) => item.user_id);
}
/** setPublic включает или выключает открытый вход на сервер. */
async function setPublic(
api: APIRequestContext,
guildId: string,
isPublic: boolean,
): Promise<void> {
const response = await repeat429(() =>
api.patch(`${API}/guilds/${guildId}`, { data: { public: isPublic } }),
);
expect(response.ok(), `публичность сервера: ${await response.text()}`).toBeTruthy();
}
/** postMessage отправляет сообщение по REST: так действует A по сценарию. */
async function postMessage(
api: APIRequestContext,
channelId: string,
content: string,
): Promise<string> {
const response = await repeat429(() =>
api.post(`${API}/channels/${channelId}/messages`, { data: { content } }),
);
expect(response.ok(), `отправка сообщения: ${await response.text()}`).toBeTruthy();
return ((await response.json()) as { message: { id: string } }).message.id;
}