diff --git a/web/e2e/voice.spec.ts b/web/e2e/voice.spec.ts new file mode 100644 index 0000000..2ca0a27 --- /dev/null +++ b/web/e2e/voice.spec.ts @@ -0,0 +1,295 @@ +import { createHmac } from 'node:crypto'; +import { + expect, + test, + type APIRequestContext, + type Browser, + type BrowserContext, + type Page, +} from '@playwright/test'; + +/** + * Голосовой e2e: два реальных браузера входят в одну голосовую комнату и + * проверяется, что аудио действительно доходит через LiveKit (AGENT.md 11.2, + * 7.14). Запуск только по явному флагу — тест требует развёрнутого инстанса: + * + * GLCHAT_E2E_VOICE=1 GLCHAT_URL=https://gl.mhspx.su \ + * GLCHAT_ADMIN_EMAIL=admin@gl.mhspx.su GLCHAT_ADMIN_PASSWORD=... \ + * GLCHAT_ADMIN_TOTP=... PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \ + * npx playwright test + */ +const enabled = process.env.GLCHAT_E2E_VOICE === '1'; +const ADMIN_EMAIL = process.env.GLCHAT_ADMIN_EMAIL ?? ''; +const ADMIN_PASSWORD = process.env.GLCHAT_ADMIN_PASSWORD ?? ''; +const ADMIN_TOTP = process.env.GLCHAT_ADMIN_TOTP ?? ''; + +/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */ +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'); +} + +interface Fixture { + guildId: string; + guildName: string; + channelId: string; + guestEmail: string; + guestPassword: string; + adminId: string; + guestId: string; +} + +/** + * dropStaleGuilds удаляет тестовые серверы прошлых запусков: если прогон упал + * до уборки, они остаются в рейле и мешают выбрать нужный сервер по имени. + */ +async function dropStaleGuilds(api: APIRequestContext): Promise { + 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('Voice E2E ')) { + await api.delete(`/api/v1/guilds/${guild.id}`); + } + } +} + +/** + * finishOnboarding помечает первичную настройку пройденной, если она ещё не + * завершена: иначе AuthGuard уводит браузер с `/app` на `/onboarding` и тест + * не доходит до голосовой комнаты. Пустое тело запроса ничего не перезаписывает. + */ +async function finishOnboarding(api: APIRequestContext): Promise { + 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(); +} + +/** prepare создаёт сервер, голосовую комнату и второго пользователя по приглашению. */ +async function prepare(api: APIRequestContext): Promise { + const adminLogin = await api.post('/api/v1/auth/login', { + data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD, totp_code: totp(ADMIN_TOTP) }, + }); + expect(adminLogin.ok(), `вход администратора: ${await adminLogin.text()}`).toBeTruthy(); + const admin = (await adminLogin.json()) as { user: { id: string } }; + await finishOnboarding(api); + await dropStaleGuilds(api); + + const stamp = Date.now(); + const guildName = `Voice E2E ${stamp}`; + const guild = await api.post('/api/v1/guilds', { data: { name: guildName } }); + expect(guild.ok(), `создание сервера: ${await guild.text()}`).toBeTruthy(); + const guildId = ((await guild.json()) as { guild: { id: string } }).guild.id; + + const channel = await api.post(`/api/v1/guilds/${guildId}/channels`, { + data: { name: 'Голос', type: 'voice' }, + }); + expect(channel.ok(), `создание комнаты: ${await channel.text()}`).toBeTruthy(); + const channelId = ((await channel.json()) as { channel: { id: string } }).channel.id; + + const invite = await api.post(`/api/v1/guilds/${guildId}/invites`, { + data: { max_uses: 1, max_age_seconds: 600 }, + }); + expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy(); + const code = ((await invite.json()) as { invite: { code: string } }).invite.code; + + const guestLogin = `voice${stamp}`; + const guestEmail = `${guestLogin}@gl.mhspx.su`; + const guestPassword = 'Voice-Probe-Password-9x'; + const guestContext = await api.post('/api/v1/auth/register', { + data: { + username: guestLogin, + email: guestEmail, + password: guestPassword, + }, + }); + expect(guestContext.ok(), `регистрация гостя: ${await guestContext.text()}`).toBeTruthy(); + const guestId = ((await guestContext.json()) as { user: { id: string } }).user.id; + // Регистрация переключила сессию api-контекста на гостя — доводим его до /app. + await finishOnboarding(api); + + const accept = await api.post(`/api/v1/invites/${code}`); + expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy(); + + return { + guildId, + guildName, + channelId, + guestEmail, + guestPassword, + adminId: admin.user.id, + guestId, + }; +} + +/** instrumentPeerConnections собирает RTC-статистику страницы. */ +async function instrumentPeerConnections(context: BrowserContext): Promise { + await context.addInitScript(() => { + const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] }; + globalWindow.__pcs = []; + const Original = window.RTCPeerConnection; + window.RTCPeerConnection = function Patched( + ...args: ConstructorParameters + ) { + const pc = new Original(...args); + globalWindow.__pcs?.push(pc); + return pc; + } as unknown as typeof RTCPeerConnection; + window.RTCPeerConnection.prototype = Original.prototype; + }); +} + +/** inboundAudioBytes суммирует полученные аудио-байты по всем соединениям. */ +async function inboundAudioBytes(page: Page): Promise { + return page.evaluate(async () => { + const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] }; + let total = 0; + for (const pc of globalWindow.__pcs ?? []) { + const stats = await pc.getStats(); + stats.forEach((raw) => { + // Отчёты RTC приходят нетипизированными: приводим к словарю и читаем поля. + const report = raw as Record; + if (report['type'] === 'inbound-rtp' && report['kind'] === 'audio') { + total += Number(report['bytesReceived'] ?? 0); + } + }); + } + return total; + }); +} + +/** signIn логинит браузерный контекст и открывает приложение. */ +async function signIn( + browser: Browser, + options: { email: string; password: string; totp?: string }, +): Promise<{ context: BrowserContext; page: Page }> { + const context = await browser.newContext({ permissions: ['microphone'] }); + await instrumentPeerConnections(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); + if (options.totp !== undefined) { + await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp)); + } + await page.getByRole('button', { name: /Войти/u }).click(); + await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 }); + return { context, page }; +} + +test.describe('голос: два клиента в одной комнате', () => { + test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_VOICE=1'); + test.skip(ADMIN_EMAIL === '' || ADMIN_PASSWORD === '', 'нужны GLCHAT_ADMIN_EMAIL/PASSWORD/TOTP'); + + test('аудио доходит через LiveKit, модерация применяется', async ({ browser, playwright }) => { + const api = await playwright.request.newContext({ + baseURL: process.env.GLCHAT_URL ?? 'https://gl.mhspx.su', + }); + const fixture = await prepare(api); + let admin: Awaited> | null = null; + let guest: Awaited> | null = null; + + try { + admin = await signIn(browser, { + email: ADMIN_EMAIL, + password: ADMIN_PASSWORD, + totp: ADMIN_TOTP, + }); + guest = await signIn(browser, { + email: fixture.guestEmail, + password: fixture.guestPassword, + }); + const adminSession = admin; + const guestSession = guest; + + // Оба клиента открывают голосовую комнату и подключаются. + for (const session of [adminSession, guestSession]) { + await session.page + .getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true }) + .click(); + await session.page.getByRole('link', { name: /Открыть комнату Голос/u }).click(); + await session.page.getByTestId('voice-join').click(); + } + + // Каждый видит обоих участников. + for (const session of [admin, guest]) { + await expect(session.page.getByTestId(`voice-tile-${fixture.adminId}`)).toBeVisible(); + await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible(); + } + + // Аудио реально приходит: ждём ненулевые байты входящего аудио-потока. + for (const session of [adminSession, guestSession]) { + await expect + .poll(async () => inboundAudioBytes(session.page), { timeout: 60_000, intervals: [1000] }) + .toBeGreaterThan(0); + } + + // Серверный мьют гостя применяется на стороне SFU. + const mute = await api.patch( + `/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`, + { + data: { server_mute: true }, + }, + ); + expect(mute.ok(), `серверный мьют: ${await mute.text()}`).toBeTruthy(); + // Кнопка микрофона у гостя блокируется серверным мьютом. + const guestMic = guestSession.page + .getByRole('group', { name: /Управление голосом/u }) + .getByRole('button') + .first(); + await expect(guestMic).toBeDisabled({ timeout: 30_000 }); + + // Отключение участника от голосовой комнаты убирает его из списка. + const drop = await api.delete( + `/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`, + ); + expect(drop.ok(), `отключение от голосовой: ${await drop.text()}`).toBeTruthy(); + await expect(guestSession.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeHidden({ + timeout: 30_000, + }); + await expect(adminSession.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeHidden({ + timeout: 30_000, + }); + } finally { + // Уборка: тестовый сервер удаляется вместе с комнатами и состояниями. + await api.delete(`/api/v1/guilds/${fixture.guildId}`); + await admin?.context.close(); + await guest?.context.close(); + await api.dispose(); + } + }); +}); diff --git a/web/eslint.config.js b/web/eslint.config.js index cc855a4..4b59a0c 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -34,7 +34,7 @@ export default tseslint.config( }, }, { - files: ['tests/**/*.{ts,tsx}'], + files: ['tests/**/*.{ts,tsx}', 'e2e/**/*.ts', 'playwright.config.ts'], languageOptions: { globals: { ...globals.node } }, }, { diff --git a/web/package-lock.json b/web/package-lock.json index 36b59d2..03180c5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "4.3.3", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", @@ -847,6 +848,22 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@remix-run/route-pattern": { "version": "0.22.1", "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", @@ -3573,6 +3590,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/web/package.json b/web/package.json index 9145a9f..4aa29c1 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "4.3.3", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..dae2f06 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright-конфигурация glchat: e2e-тесты идут против уже развёрнутого + * инстанса (адрес в GLCHAT_URL, по умолчанию стенд), а не поднимают сервер — + * так проверяется реальный путь через прокси, TLS и TURN (AGENT.md 11.2). + */ +export default defineConfig({ + testDir: './e2e', + timeout: 180_000, + expect: { timeout: 20_000 }, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['list']], + use: { + baseURL: process.env.GLCHAT_URL ?? 'https://gl.mhspx.su', + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + launchOptions: { + // Фейковые медиа-устройства: Chromium отдаёт синтетический звук, поэтому + // проверяется реальная доставка аудио через LiveKit (AGENT.md 11.2). + args: [ + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', + '--autoplay-policy=no-user-gesture-required', + '--no-sandbox', + ], + }, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/web/tsconfig.e2e.json b/web/tsconfig.e2e.json new file mode 100644 index 0000000..104c02e --- /dev/null +++ b/web/tsconfig.e2e.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "noEmit": true, + "composite": true, + "tsBuildInfoFile": "./node_modules/.cache/tsconfig.e2e.tsbuildinfo" + }, + "include": ["playwright.config.ts", "e2e/**/*.ts"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json index d32ff68..a999c8a 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,4 +1,8 @@ { "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.e2e.json" } + ] }