fix(realtime): уход на вход после отзыва сессии и бан в открытом сервере

- invalidateCurrentUser перечитывает профиль, а не только удаляет кэш: без
  запроса AuthGuard не видит 401 и второе устройство остаётся в приложении
  после logout-all или смены пароля (AGENT.md 11.6, пункт 12);
- сброс сессии и уход из последнего сервера инвалидируют список серверов:
  пустой снапшот подставлял устаревший REST-ответ, и сервер возвращался в
  рейку (видно и после кика);
- joinGuild проверяет бан сервера: публичный сервер больше не обходится
  нажатием «Войти» вместо приглашения (AGENT.md 7.17, 7.20), тест расширен;
- e2e/voice-ten приведён к линту, console.log разрешён в e2e (диагностика
  прогона), артефакты Playwright исключены из prettier.
This commit is contained in:
2026-09-22 20:35:13 +03:00
parent b2a48d8008
commit 7ec9dbfdee
6 changed files with 55 additions and 48 deletions
+15 -46
View File
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs';
import { expect, test, type Browser, type BrowserContext, type Page } from '@playwright/test';
import { expect, test, type BrowserContext, type Page } from '@playwright/test';
/**
* Голосовая нагрузка (AGENT.md 9.1, 11.4): десять участников в одной комнате
@@ -17,8 +17,6 @@ const CREDS_PATH = process.env.GLCHAT_LOADTEST_CREDS ?? '../../build/loadtest-us
const FIXTURE_PATH = process.env.GLCHAT_LOADTEST_FIXTURE ?? '../../build/loadtest-fixture.json';
const PARTICIPANTS = Number(process.env.GLCHAT_VOICE_PARTICIPANTS ?? '10');
const EXPECT_SCREENSHARE = process.env.GLCHAT_EXPECT_SCREENSHARE === '1';
// Идентификатор внешнего издателя: реальный участник сервера (см. build/loadtest-voice.sh).
const SCREENSHARE_IDENTITY = process.env.GLCHAT_SCREENSHARE_IDENTITY ?? 'screenshare-1080p60';
interface Account {
id: string;
@@ -40,41 +38,6 @@ function readFixture(): Fixture {
return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Fixture;
}
/** signInWithToken открывает контекст с готовой сессией: форма входа не нужна. */
async function signInWithToken(
browser: Browser,
account: Account,
baseURL: string,
): Promise<{ context: BrowserContext; page: Page }> {
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
baseURL,
});
await context.addCookies([
// Cookie сессии — __Host-session (internal/gateway/session.go).
{ name: '__Host-session', value: account.token, url: baseURL, httpOnly: true, secure: true },
]);
// Аккаунты создаёт команда обслуживания, онбординг у них не пройден: без
// этого AuthGuard уводит браузер на /onboarding и комнаты не видно.
const headers = { Authorization: `Bearer ${account.token}` };
const me = await context.request.get('/api/v1/users/@me', { headers });
if (me.ok()) {
const payload = (await me.json()) as {
user: { onboarding_completed: boolean; display_name?: string | null; username: string };
};
if (!payload.user.onboarding_completed) {
await context.request.post('/api/v1/users/@me/onboarding/complete', {
headers,
data: { display_name: payload.user.display_name ?? payload.user.username },
});
}
}
const page = await context.newPage();
await page.goto('/app');
await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 });
return { context, page };
}
/** joinVoice нажимает «Войти» в голосовой комнате и ждёт подключения. */
async function joinVoice(page: Page): Promise<void> {
const button = page.getByTestId('voice-join');
@@ -101,11 +64,17 @@ async function inboundAudioBytes(page: Page): Promise<number> {
});
}
/** inboundVideoStats отдаёт лучший входящий видеопоток: размер и кадры в секунду. */
async function inboundVideoStats(
page: Page,
): Promise<{ width: number; height: number; fps: number }> {
return page.evaluate(async () => {
interface VideoStats {
width: number;
height: number;
fps: number;
decoded: number;
bytes: number;
}
/** inboundVideoStats отдаёт лучший входящий видеопоток: размер, кадры и байты. */
async function inboundVideoStats(page: Page): Promise<VideoStats> {
return page.evaluate<VideoStats>(async () => {
const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] };
let best = { width: 0, height: 0, fps: 0, decoded: 0, bytes: 0 };
for (const pc of globalWindow.__pcs ?? []) {
@@ -209,15 +178,15 @@ test.describe('голосовая нагрузка: десять участни
// декодирование 60 кадров в секунду меряет независимый подписчик —
// build/verify-1080p60-browser.mjs (adaptive stream выключен).
await expect
.poll(async () => inboundVideoStats(sessions[0]!.page), {
.poll(async () => inboundVideoStats(sessions[0].page), {
timeout: 90_000,
intervals: [2000],
})
.toMatchObject({ width: 1920, height: 1080 });
const stats = await inboundVideoStats(sessions[0]!.page);
const stats = await inboundVideoStats(sessions[0].page);
console.log('входящее видео:', JSON.stringify(stats));
await expect
.poll(async () => (await inboundVideoStats(sessions[0]!.page)).bytes, {
.poll(async () => (await inboundVideoStats(sessions[0].page)).bytes, {
timeout: 60_000,
intervals: [2000],
})