2026-09-26 14:15:53 +03:00
|
|
|
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
2026-09-26 11:06:51 +03:00
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
apiAs,
|
|
|
|
|
|
assignRole,
|
|
|
|
|
|
cleanupGuild,
|
|
|
|
|
|
createChannel,
|
|
|
|
|
|
createRole,
|
|
|
|
|
|
displayName,
|
|
|
|
|
|
dropStaleGuilds,
|
|
|
|
|
|
ensureProbeWithWait,
|
|
|
|
|
|
freshState,
|
2026-09-26 14:15:53 +03:00
|
|
|
|
joinVoiceOrSkip,
|
2026-09-26 11:06:51 +03:00
|
|
|
|
openChannel,
|
|
|
|
|
|
openGuild,
|
|
|
|
|
|
openWorkspace,
|
|
|
|
|
|
patchChannel,
|
|
|
|
|
|
postMessage,
|
|
|
|
|
|
prepareGuild,
|
|
|
|
|
|
repeat429,
|
|
|
|
|
|
sessionFrom,
|
|
|
|
|
|
stepUp,
|
|
|
|
|
|
trackLoads,
|
|
|
|
|
|
uploadSound,
|
|
|
|
|
|
type Account,
|
|
|
|
|
|
type GuildFixture,
|
|
|
|
|
|
type ProbeLogin,
|
|
|
|
|
|
type Session,
|
|
|
|
|
|
} from './support';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* UI шапки сервера и саундборда — то, что в матрице 11.6 проверялось только по
|
|
|
|
|
|
* REST: кнопки ⚙/🔗/+ по правам, статус комнаты в шапке, счётчик непрочитанных
|
|
|
|
|
|
* и панель саундборда с локальным воспроизведением звука (AGENT.md 7.13, 7.15).
|
|
|
|
|
|
* Запуск только по флагу, тест требует стенда:
|
|
|
|
|
|
*
|
|
|
|
|
|
* GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \
|
|
|
|
|
|
* PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \
|
|
|
|
|
|
* npx playwright test e2e/guild-ui.spec.ts
|
|
|
|
|
|
*/
|
|
|
|
|
|
const enabled = process.env.GLCHAT_E2E_REALTIME === '1';
|
|
|
|
|
|
const API = '/api/v1';
|
|
|
|
|
|
/** Префикс имени тестового сервера: по нему убираются серверы прошлых прогонов. */
|
|
|
|
|
|
const GUILD_PREFIX = 'UI E2E ';
|
|
|
|
|
|
/** Постоянные пробные аккаунты: A — владелец, B — обычный участник. */
|
|
|
|
|
|
const OWNER_LOGIN: ProbeLogin = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' };
|
|
|
|
|
|
const MEMBER_LOGIN: ProbeLogin = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' };
|
|
|
|
|
|
/** Права роли, после выдачи которой кнопки шапки появляются у участника. */
|
|
|
|
|
|
const MANAGER_PERMISSIONS =
|
|
|
|
|
|
'VIEW_GUILD|VIEW_CHANNEL|SEND_MESSAGES|MANAGE_GUILD|MANAGE_CHANNELS|CREATE_INVITES|MANAGE_WEBHOOKS';
|
|
|
|
|
|
|
|
|
|
|
|
interface UiFixture extends GuildFixture {
|
|
|
|
|
|
secondChannelId: string;
|
|
|
|
|
|
secondChannelName: string;
|
|
|
|
|
|
voiceChannelId: string;
|
|
|
|
|
|
voiceChannelName: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let owner: Account;
|
|
|
|
|
|
let member: Account;
|
|
|
|
|
|
|
|
|
|
|
|
test.describe('UI шапки сервера, комнаты и саундборда', () => {
|
|
|
|
|
|
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_REALTIME=1');
|
|
|
|
|
|
|
|
|
|
|
|
// Лимит 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: standBase() });
|
|
|
|
|
|
const memberApi = await playwright.request.newContext({ baseURL: standBase() });
|
|
|
|
|
|
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);
|
|
|
|
|
|
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),
|
|
|
|
|
|
};
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
|
|
|
|
member = {
|
|
|
|
|
|
...memberProbe,
|
|
|
|
|
|
displayName: memberName,
|
|
|
|
|
|
state: await memberApi.storageState(),
|
|
|
|
|
|
browserState: await freshState(memberApi, MEMBER_LOGIN),
|
|
|
|
|
|
};
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await ownerApi.dispose();
|
|
|
|
|
|
await memberApi.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('25. Шапка сервера: 🔗/⚙/+ видны владельцу и появляются у участника по правам', async ({
|
|
|
|
|
|
browser,
|
|
|
|
|
|
playwright,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const ownerApi = await apiAs(playwright, owner.state);
|
|
|
|
|
|
const memberApi = await apiAs(playwright, member.state);
|
|
|
|
|
|
const sessions: Session[] = [];
|
|
|
|
|
|
let fixture: UiFixture | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
fixture = await prepareUiGuild(ownerApi, memberApi);
|
|
|
|
|
|
// Владелец: в шапке сервера есть приглашение, настройки и создание комнаты.
|
|
|
|
|
|
const ownerSession = await sessionFrom(browser, owner.browserState);
|
|
|
|
|
|
sessions.push(ownerSession);
|
|
|
|
|
|
await openWorkspace(ownerSession.page, fixture);
|
|
|
|
|
|
await expect(ownerSession.page.getByTestId('guild-invite')).toBeVisible({ timeout: 20_000 });
|
|
|
|
|
|
await expect(ownerSession.page.getByLabel('Настройки сервера')).toBeVisible();
|
|
|
|
|
|
await expect(ownerSession.page.getByLabel('Создать комнату')).toBeVisible();
|
|
|
|
|
|
// Шестерёнка комнаты (вебхуки и доступ) — тоже по правам.
|
|
|
|
|
|
await expect(ownerSession.page.getByTestId('channel-settings')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
// Обычный участник: кнопок управления нет, но комната и композер доступны.
|
|
|
|
|
|
const memberSession = await sessionFrom(browser, member.browserState);
|
|
|
|
|
|
sessions.push(memberSession);
|
|
|
|
|
|
await openWorkspace(memberSession.page, fixture);
|
|
|
|
|
|
const memberLoads = trackLoads(memberSession.page);
|
|
|
|
|
|
memberLoads.reset();
|
|
|
|
|
|
await expect(memberSession.page.getByTestId('channel-header')).toBeVisible();
|
|
|
|
|
|
await expect(memberSession.page.getByTestId('guild-invite')).toHaveCount(0);
|
|
|
|
|
|
await expect(memberSession.page.getByLabel('Настройки сервера')).toHaveCount(0);
|
|
|
|
|
|
await expect(memberSession.page.getByLabel('Создать комнату')).toHaveCount(0);
|
|
|
|
|
|
await expect(memberSession.page.getByTestId('channel-settings')).toHaveCount(0);
|
|
|
|
|
|
await expect(memberSession.page.getByTestId('composer')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
// Выдача прав роли: кнопки появляются без перезагрузки (AGENT.md 11.6).
|
|
|
|
|
|
const role = await createRole(ownerApi, fixture.guildId, {
|
|
|
|
|
|
name: 'Управляющий',
|
|
|
|
|
|
permissions: MANAGER_PERMISSIONS,
|
|
|
|
|
|
});
|
|
|
|
|
|
await assignRole(ownerApi, fixture.guildId, member.id, role.id);
|
|
|
|
|
|
await expect(memberSession.page.getByTestId('guild-invite')).toBeVisible({ timeout: 20_000 });
|
|
|
|
|
|
await expect(memberSession.page.getByLabel('Настройки сервера')).toBeVisible({
|
|
|
|
|
|
timeout: 20_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(memberSession.page.getByLabel('Создать комнату')).toBeVisible({
|
|
|
|
|
|
timeout: 20_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(memberLoads.count(), 'права применились без полной загрузки документа').toBe(0);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
for (const session of sessions) {
|
|
|
|
|
|
await session.context.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
await cleanupGuild(ownerApi, fixture, OWNER_LOGIN.password);
|
|
|
|
|
|
await ownerApi.dispose();
|
|
|
|
|
|
await memberApi.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('26. Шапка комнаты: статус и медленный режим без F5', async ({ browser, playwright }) => {
|
|
|
|
|
|
const ownerApi = await apiAs(playwright, owner.state);
|
|
|
|
|
|
const memberApi = await apiAs(playwright, member.state);
|
|
|
|
|
|
let session: Session | null = null;
|
|
|
|
|
|
let fixture: UiFixture | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
fixture = await prepareUiGuild(ownerApi, memberApi);
|
|
|
|
|
|
session = await sessionFrom(browser, member.browserState);
|
|
|
|
|
|
const page = session.page;
|
|
|
|
|
|
await openWorkspace(page, fixture);
|
|
|
|
|
|
const loads = trackLoads(page);
|
|
|
|
|
|
loads.reset();
|
|
|
|
|
|
|
|
|
|
|
|
// Статус комнаты: описание приходит событием и показывается в шапке.
|
|
|
|
|
|
const description = `Статус ${Date.now()}`;
|
|
|
|
|
|
await patchChannel(ownerApi, fixture.guildId, fixture.textChannelId, { description });
|
|
|
|
|
|
await expect(page.getByTestId('channel-description')).toHaveText(description, {
|
|
|
|
|
|
timeout: 20_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Медленный режим: в шапке появляется подпись с интервалом.
|
|
|
|
|
|
await patchChannel(ownerApi, fixture.guildId, fixture.textChannelId, {
|
|
|
|
|
|
slowmode_seconds: 5,
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(page.getByTestId('channel-header')).toContainText('медленный режим 5 с', {
|
|
|
|
|
|
timeout: 20_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Снятие ограничений убирает подписи: шапка следует за сервером.
|
|
|
|
|
|
await patchChannel(ownerApi, fixture.guildId, fixture.textChannelId, {
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
slowmode_seconds: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(page.getByTestId('channel-description')).toHaveCount(0, { timeout: 20_000 });
|
|
|
|
|
|
expect(loads.count(), 'шапка обновлялась без полной загрузки документа').toBe(0);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await session?.context.close();
|
|
|
|
|
|
await cleanupGuild(ownerApi, fixture, OWNER_LOGIN.password);
|
|
|
|
|
|
await ownerApi.dispose();
|
|
|
|
|
|
await memberApi.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('27. Саундборд: панель открывается, звук играет локально', async ({
|
|
|
|
|
|
browser,
|
|
|
|
|
|
playwright,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const ownerApi = await apiAs(playwright, owner.state);
|
|
|
|
|
|
const memberApi = await apiAs(playwright, member.state);
|
|
|
|
|
|
let session: Session | null = null;
|
|
|
|
|
|
let fixture: UiFixture | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
fixture = await prepareUiGuild(ownerApi, memberApi);
|
|
|
|
|
|
// Звук загружаем до входа в браузер: список приходит снапшотом сервера.
|
|
|
|
|
|
const soundId = await uploadSound(ownerApi, fixture.guildId, 'zvonok', 'soundboard');
|
|
|
|
|
|
|
|
|
|
|
|
const context = await browser.newContext({ storageState: owner.browserState });
|
|
|
|
|
|
// Считаем локальное воспроизведение: сервер только рассылает событие, а
|
|
|
|
|
|
// файл каждый клиент играет сам (`new Audio(...)` + `play()`, AGENT.md 7.13).
|
|
|
|
|
|
await context.addInitScript(() => {
|
|
|
|
|
|
const scope = window as unknown as { __soundCalls?: number; __soundPlayed?: number };
|
|
|
|
|
|
scope.__soundCalls = 0;
|
|
|
|
|
|
scope.__soundPlayed = 0;
|
|
|
|
|
|
const OriginalAudio = window.Audio;
|
|
|
|
|
|
window.Audio = function PatchedAudio(src?: string) {
|
|
|
|
|
|
const element = new OriginalAudio(src);
|
|
|
|
|
|
const original = element.play.bind(element);
|
|
|
|
|
|
element.play = () => {
|
|
|
|
|
|
scope.__soundCalls = (scope.__soundCalls ?? 0) + 1;
|
|
|
|
|
|
return original().then((value: void) => {
|
|
|
|
|
|
scope.__soundPlayed = (scope.__soundPlayed ?? 0) + 1;
|
|
|
|
|
|
return value;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
return element;
|
|
|
|
|
|
} as unknown as typeof Audio;
|
|
|
|
|
|
});
|
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
|
await page.goto('/app');
|
|
|
|
|
|
await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 });
|
|
|
|
|
|
session = { context, page };
|
|
|
|
|
|
|
|
|
|
|
|
// Входим в голосовую комнату один: панель саундборда живёт в плитке
|
|
|
|
|
|
// участника и без голосовой сессии сервер ответит `voice.not_connected`.
|
2026-09-26 14:15:53 +03:00
|
|
|
|
// При недоступном медиапути (ICE) проверка скипается с точной причиной,
|
|
|
|
|
|
// а дефект серверной части входа в голос валит тест (support.ts).
|
2026-09-26 11:06:51 +03:00
|
|
|
|
await openGuild(page, fixture.guildName);
|
|
|
|
|
|
await openChannel(page, fixture.voiceChannelName);
|
2026-09-26 14:15:53 +03:00
|
|
|
|
await joinVoiceOrSkip(page, ownerApi, fixture.voiceChannelId, fixture.voiceChannelName);
|
2026-09-26 11:06:51 +03:00
|
|
|
|
await expect(page.getByTestId('voice-soundboard-button')).toBeVisible({ timeout: 30_000 });
|
|
|
|
|
|
await page.getByTestId('voice-soundboard-button').click();
|
|
|
|
|
|
const panel = page.getByTestId('voice-soundboard-panel');
|
|
|
|
|
|
await expect(panel).toBeVisible({ timeout: 20_000 });
|
|
|
|
|
|
const sound = panel.getByTestId(`voice-soundboard-sound-${soundId}`);
|
|
|
|
|
|
await expect(sound).toBeVisible({ timeout: 20_000 });
|
|
|
|
|
|
|
|
|
|
|
|
// Проигрывание: сервер отвечает 200, звук играет локально и всплывает подпись.
|
|
|
|
|
|
const played = page.waitForResponse(
|
|
|
|
|
|
(response) =>
|
|
|
|
|
|
response.request().method() === 'POST' &&
|
|
|
|
|
|
response.url().includes(`/sounds/${soundId}/play`),
|
|
|
|
|
|
{ timeout: 20_000 },
|
|
|
|
|
|
);
|
|
|
|
|
|
await sound.click();
|
|
|
|
|
|
expect((await played).status(), 'сервер принял проигрывание звука').toBe(200);
|
|
|
|
|
|
await expect(page.getByTestId('soundboard-notice')).toContainText('zvonok', {
|
|
|
|
|
|
timeout: 20_000,
|
|
|
|
|
|
});
|
2026-09-26 14:42:47 +03:00
|
|
|
|
// Локальное воспроизведение асинхронно: клиент сначала скачивает файл
|
|
|
|
|
|
// (`/files/{id}` → blob), и только потом создаёт `Audio` и зовёт `play()`.
|
|
|
|
|
|
// Подпись появляется раньше звука, поэтому счётчики не читаем сразу —
|
|
|
|
|
|
// дожидаемся факта запуска (иначе проверка ловит гонку, а не дефект).
|
|
|
|
|
|
const counter = (name: '__soundCalls' | '__soundPlayed'): Promise<number> =>
|
|
|
|
|
|
page.evaluate(
|
|
|
|
|
|
(key) => (window as unknown as Record<string, number | undefined>)[key] ?? 0,
|
|
|
|
|
|
name,
|
|
|
|
|
|
);
|
|
|
|
|
|
await expect
|
|
|
|
|
|
.poll(() => counter('__soundCalls'), {
|
|
|
|
|
|
message: 'клиент запустил локальное воспроизведение',
|
|
|
|
|
|
timeout: 20_000,
|
|
|
|
|
|
})
|
|
|
|
|
|
.toBeGreaterThan(0);
|
|
|
|
|
|
await expect
|
|
|
|
|
|
.poll(() => counter('__soundPlayed'), {
|
|
|
|
|
|
message: 'звук действительно заиграл локально',
|
|
|
|
|
|
timeout: 20_000,
|
|
|
|
|
|
})
|
|
|
|
|
|
.toBeGreaterThan(0);
|
2026-09-26 11:06:51 +03:00
|
|
|
|
} finally {
|
|
|
|
|
|
await session?.context.close();
|
|
|
|
|
|
await cleanupGuild(ownerApi, fixture, OWNER_LOGIN.password);
|
|
|
|
|
|
await ownerApi.dispose();
|
|
|
|
|
|
await memberApi.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('28. Счётчик непрочитанных и отсутствие центра уведомлений', async ({
|
|
|
|
|
|
browser,
|
|
|
|
|
|
playwright,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const ownerApi = await apiAs(playwright, owner.state);
|
|
|
|
|
|
const memberApi = await apiAs(playwright, member.state);
|
|
|
|
|
|
let session: Session | null = null;
|
|
|
|
|
|
let fixture: UiFixture | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
fixture = await prepareUiGuild(ownerApi, memberApi);
|
|
|
|
|
|
session = await sessionFrom(browser, member.browserState);
|
|
|
|
|
|
const page = session.page;
|
|
|
|
|
|
await openWorkspace(page, fixture);
|
|
|
|
|
|
// История должна быть загружена: отметка прочтения указывает на реальное
|
|
|
|
|
|
// сообщение, иначе счётчик непрочитанных посчитать нечем.
|
|
|
|
|
|
await expect(page.getByTestId('message-list').locator('article').first()).toBeVisible({
|
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
// Участник уходит во вторую комнату: первая становится непрочитанной.
|
|
|
|
|
|
// Отметку ставим ручкой сервера: в headless окно не в фокусе, и
|
|
|
|
|
|
// автоотметка клиента не срабатывает (так же делает матрица 11.6).
|
|
|
|
|
|
const lastId = newestMessageId(await channelMessages(memberApi, fixture.textChannelId));
|
|
|
|
|
|
const ack = await repeat429(() =>
|
|
|
|
|
|
memberApi.post(`${API}/channels/${fixture?.textChannelId ?? ''}/ack`, {
|
|
|
|
|
|
data: { last_message_id: lastId },
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(ack.ok(), `отметка прочтения: ${await ack.text()}`).toBeTruthy();
|
|
|
|
|
|
await openChannel(page, fixture.secondChannelName);
|
|
|
|
|
|
await expect(page.getByTestId('channel-header')).toContainText(fixture.secondChannelName);
|
|
|
|
|
|
const loads = trackLoads(page);
|
|
|
|
|
|
loads.reset();
|
|
|
|
|
|
|
|
|
|
|
|
await postMessage(ownerApi, fixture.textChannelId, `непрочитанное ${Date.now()}`);
|
|
|
|
|
|
const badge = page.getByTestId(`channel-unread-${fixture.textChannelId}`);
|
|
|
|
|
|
await expect(badge).toBeVisible({ timeout: 20_000 });
|
|
|
|
|
|
await expect(badge).toHaveText('1');
|
|
|
|
|
|
|
|
|
|
|
|
// Центра уведомлений в клиенте нет: «Уведомления» — только переключатель
|
|
|
|
|
|
// системных уведомлений в настройках (заглушка, отмечено в отчёте).
|
|
|
|
|
|
expect(
|
|
|
|
|
|
await page.getByRole('link', { name: /Уведомления/u }).count(),
|
|
|
|
|
|
'в оболочке нет ссылки на центр уведомлений',
|
|
|
|
|
|
).toBe(0);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
await page.locator('[data-testid*="notification"]').count(),
|
|
|
|
|
|
'в DOM нет элементов центра уведомлений',
|
|
|
|
|
|
).toBe(0);
|
|
|
|
|
|
|
|
|
|
|
|
// Открытие комнаты гасит счётчик: отметка прочтения уходит на сервер.
|
|
|
|
|
|
await openChannel(page, fixture.textChannelName);
|
|
|
|
|
|
await expect(badge).toHaveCount(0, { timeout: 20_000 });
|
|
|
|
|
|
expect(loads.count(), 'счётчик менялся без полной загрузки документа').toBe(0);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await session?.context.close();
|
|
|
|
|
|
await cleanupGuild(ownerApi, fixture, OWNER_LOGIN.password);
|
|
|
|
|
|
await ownerApi.dispose();
|
|
|
|
|
|
await memberApi.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** standBase — адрес стенда: тот же, что у Playwright (GLCHAT_URL). */
|
|
|
|
|
|
function standBase(): string {
|
|
|
|
|
|
return process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** channelMessages читает историю комнаты: нужна отметка прочтения. */
|
|
|
|
|
|
async function channelMessages(api: APIRequestContext, channelId: string): Promise<unknown[]> {
|
|
|
|
|
|
const response = await repeat429(() => api.get(`${API}/channels/${channelId}/messages`));
|
|
|
|
|
|
if (!response.ok()) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
return ((await response.json()) as { messages: unknown[] }).messages;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** newestMessageId выбирает самый свежий id из истории комнаты. */
|
|
|
|
|
|
function newestMessageId(messages: unknown[]): string {
|
|
|
|
|
|
let newest = '';
|
|
|
|
|
|
for (const item of messages) {
|
|
|
|
|
|
const id = (item as { id?: string }).id ?? '';
|
|
|
|
|
|
if (id === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (newest === '' || BigInt(id) > BigInt(newest)) {
|
|
|
|
|
|
newest = id;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return newest;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** prepareUiGuild готовит сервер с двумя текстовыми и одной голосовой комнатой. */
|
|
|
|
|
|
async function prepareUiGuild(
|
|
|
|
|
|
ownerApi: APIRequestContext,
|
|
|
|
|
|
memberApi: APIRequestContext,
|
|
|
|
|
|
): Promise<UiFixture> {
|
|
|
|
|
|
const fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`);
|
|
|
|
|
|
const second = await createChannel(ownerApi, fixture.guildId, {
|
|
|
|
|
|
name: 'вторая',
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
});
|
|
|
|
|
|
const voice = await createChannel(ownerApi, fixture.guildId, {
|
|
|
|
|
|
name: 'Голос',
|
|
|
|
|
|
type: 'voice',
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
...fixture,
|
|
|
|
|
|
secondChannelId: second.id,
|
|
|
|
|
|
secondChannelName: second.name,
|
|
|
|
|
|
voiceChannelId: voice.id,
|
|
|
|
|
|
voiceChannelName: voice.name,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|