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:
+15
-46
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user