From 6cd062eb2115e7911b11c4974038a9de8eb590f5 Mon Sep 17 00:00:00 2001 From: Maksim Sokolov Date: Fri, 13 Nov 2026 06:55:38 +0000 Subject: [PATCH 1/3] feat(audio): default microphone processing off with one shared switch --- CHANGELOG.md | 8 ++ apps/web/app/globals.css | 1 - .../GuestAudioProfileSelector.module.css | 14 +++ .../components/GuestAudioProfileSelector.tsx | 23 +++-- .../GuestNoiseSuppressionControl.tsx | 7 +- apps/web/components/GuestPrivateTalkback.tsx | 2 +- .../components/StudioGuestMediaControls.tsx | 4 +- apps/web/lib/guest-audio.ts | 4 +- apps/web/lib/i18n/workspaces.ts | 3 + apps/web/tests/caster-media-menus.browser.cjs | 2 +- apps/web/tests/guest-audio-profile-ui.cjs | 6 +- apps/web/tests/guest-audio-switch.browser.cjs | 91 +++++++++++++++++++ apps/web/tests/guest-audio-switch.cjs | 31 +++++++ apps/web/tests/guest-audio.cjs | 6 +- apps/web/tests/guest-media-controls-ui.cjs | 22 ++++- apps/web/tests/guest-media.cjs | 2 +- apps/web/tests/guest-noise-control.cjs | 17 ++-- apps/web/tests/guest-workspace-media.cjs | 1 + apps/web/tests/private-talkback-controls.cjs | 8 +- apps/web/tests/run-ci.cjs | 1 + distribution/release-tracks.json | 4 +- docs/manual/FILE_MAP_RU.md | 62 ++++++++++--- package.json | 4 +- 23 files changed, 259 insertions(+), 64 deletions(-) create mode 100644 apps/web/components/GuestAudioProfileSelector.module.css create mode 100644 apps/web/tests/guest-audio-switch.browser.cjs create mode 100644 apps/web/tests/guest-audio-switch.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b4a43b8..0047dc29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.3 — обработка микрофона + +- Шумоподавление и эхоподавление по умолчанию выключены. Ранее сохранённый явный выбор сохраняется. +- В меню настроек микрофона — единый тумблер «Шумоподавление и эхо» с текущим состоянием. +- Основная студия может включать и выключать обработку в карточке кастера без его ручного согласия. Приложение автоматически сообщает результат применения. +- Переключение сохраняет выключенное состояние микрофона, не меняет камеру и не прерывает приватное обращение. +- Обновляются web и серверные пакеты Windows Server Setup/Linux; отдельная пересборка Windows/macOS Client не нужна для этого изменения web-интерфейса. После обновления сервера перезагрузите CastLink вне эфира. + ## 1.0.2 — серверный выпуск / Server release / 服务器发行版 [Русский](distribution/RELEASE_NOTES_1_0_2.md) · [English](distribution/docs/en/RELEASE_NOTES_1_0_2.md) · [简体中文](distribution/docs/zh-CN/RELEASE_NOTES_1_0_2.md) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d28be5ec..d2aee1e1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1306,7 +1306,6 @@ html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content select { width: 100%; } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--panel-border); } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .guest-noise-control > label { display: flex; align-items: center; gap: 10px; min-height: 44px; font-size: 13px; font-weight: 600; line-height: 1.5; cursor: pointer; } -html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control input[type="checkbox"] { flex: 0 0 18px; width: 18px; height: 18px; min-height: 18px; margin: 0; padding: 0; accent-color: var(--media-accent); } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control p { margin: 4px 0 0; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .personal-monitor-panel h2 { margin: 0 0 8px; font-size: 14px; } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .personal-monitor-panel p { margin: 0 0 12px; font-size: 12px; line-height: 1.6; } diff --git a/apps/web/components/GuestAudioProfileSelector.module.css b/apps/web/components/GuestAudioProfileSelector.module.css new file mode 100644 index 00000000..b09c2370 --- /dev/null +++ b/apps/web/components/GuestAudioProfileSelector.module.css @@ -0,0 +1,14 @@ +.root { min-width: 0; width: 100%; } +.root .control { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 44px; margin: 0; cursor: pointer; } +.copy { display: grid; gap: 4px; min-width: 0; } +.title { color: var(--text); font-size: 13px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; } +.hint { color: var(--muted); font-size: 12px; font-weight: 400; line-height: 1.5; } +.root .switch { appearance: none; flex: none; box-sizing: border-box; width: 44px; height: 26px; min-height: 0; margin: 0; padding: 3px; border: 1px solid var(--field-border); border-radius: 999px; background: var(--secondary-bg); cursor: pointer; transition: background-color 160ms ease-out, border-color 160ms ease-out; } +.switch::before { content: ""; display: block; width: 18px; height: 18px; border-radius: 50%; background: var(--muted); transition: transform 160ms ease-out, background-color 160ms ease-out; } +.root .switch:checked { background: var(--primary); border-color: var(--primary); } +.switch:checked::before { transform: translateX(18px); background: var(--page-bg); } +.root .switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 4px; } +.control:hover .switch:not(:disabled) { border-color: var(--text); } +.control:has(.switch:disabled) { cursor: default; opacity: .6; } +.root .switch:disabled { cursor: default; } +@media (prefers-reduced-motion: reduce) { .root .switch, .switch::before { transition: none; } } diff --git a/apps/web/components/GuestAudioProfileSelector.tsx b/apps/web/components/GuestAudioProfileSelector.tsx index ef1644e5..25e46618 100644 --- a/apps/web/components/GuestAudioProfileSelector.tsx +++ b/apps/web/components/GuestAudioProfileSelector.tsx @@ -1,16 +1,21 @@ "use client"; +import { useId } from "react"; import { useI18n } from "@/components/I18nProvider"; import { guestAudioProfile, type GuestAudioProfile } from "@/lib/guest-audio"; +import styles from "./GuestAudioProfileSelector.module.css"; export function GuestAudioProfileSelector({ value, onChange, disabled = false, busy = false }: { value?: GuestAudioProfile; onChange: (profile: GuestAudioProfile) => void; disabled?: boolean; busy?: boolean }) { - const { t, locale } = useI18n(); - const profile = guestAudioProfile(value); - return
- - {profile === "raw" &&

{t("Без шумоподавления и автоматического усиления. Используйте наушники, чтобы избежать эха.")}

} - {busy && {t("Переключение микрофона…")}} + const { t } = useI18n(); + const id = useId(); + const enabled = guestAudioProfile(value) === "speech"; + return
+ + {busy && {t("Переключение микрофона…")}}
; } diff --git a/apps/web/components/GuestNoiseSuppressionControl.tsx b/apps/web/components/GuestNoiseSuppressionControl.tsx index ab41ebcc..84aacb95 100644 --- a/apps/web/components/GuestNoiseSuppressionControl.tsx +++ b/apps/web/components/GuestNoiseSuppressionControl.tsx @@ -1,9 +1,10 @@ "use client"; import { useI18n } from "@/components/I18nProvider"; +import { GuestAudioProfileSelector } from "./GuestAudioProfileSelector"; import type { GuestAudioProfile, GuestNoiseSuppressionState } from "@/lib/guest-audio"; -export function GuestNoiseSuppressionControl({ profile, state, confirmed, disabled, busy, onChange, asButton = false }: { +export function GuestNoiseSuppressionControl({ profile, state, confirmed, disabled, busy, onChange }: { asButton?: boolean; profile: GuestAudioProfile; state: GuestNoiseSuppressionState; @@ -20,9 +21,7 @@ export function GuestNoiseSuppressionControl({ profile, state, confirmed, disabl : speech ? "Шумоподавление и защита от эха включены." : "Оригинальный звук. Используйте наушники, чтобы избежать эха."; return
- {asButton ? : } +

{t(status)}

; } diff --git a/apps/web/components/GuestPrivateTalkback.tsx b/apps/web/components/GuestPrivateTalkback.tsx index b9407216..2074923f 100644 --- a/apps/web/components/GuestPrivateTalkback.tsx +++ b/apps/web/components/GuestPrivateTalkback.tsx @@ -14,7 +14,7 @@ import { guestAudioCaptureOptions, setGuestMicrophoneEnabled, type GuestAudioPro type PrivateSession = { id: string; room: Room; dispose: () => void; track?: LocalAudioTrack; reply: ReturnType>; restoreEpoch: number }; /** Only the held guest reply gates its microphone; the rest of the program is untouched. */ -export function GuestPrivateTalkback({ room, roomName, studioId, token, microphoneId, onReplyBusyChange, listenGain = 100, audioProfile = "speech", micGain = 100, microphoneMuteEpochRef, disabled = false }: { +export function GuestPrivateTalkback({ room, roomName, studioId, token, microphoneId, onReplyBusyChange, listenGain = 100, audioProfile, micGain = 100, microphoneMuteEpochRef, disabled = false }: { room: Room; roomName: string; studioId: string; token: string; microphoneId: string; onReplyBusyChange?: (active: boolean) => void; listenGain?: number; audioProfile?: GuestAudioProfile; micGain?: number; microphoneMuteEpochRef?: React.RefObject; disabled?: boolean; }) { const { t } = useI18n(); diff --git a/apps/web/components/StudioGuestMediaControls.tsx b/apps/web/components/StudioGuestMediaControls.tsx index a2b89758..25eb9e78 100644 --- a/apps/web/components/StudioGuestMediaControls.tsx +++ b/apps/web/components/StudioGuestMediaControls.tsx @@ -1,5 +1,6 @@ "use client"; +import { GuestAudioProfileSelector } from "./GuestAudioProfileSelector"; import { StudioGuestDeviceControls } from "./StudioGuestDeviceControls"; import { GuestCameraCaptureControls } from "./GuestCameraCaptureControls"; import { useI18n } from "@/components/I18nProvider"; @@ -71,13 +72,12 @@ export function StudioGuestMediaControls({ studioId, roomName, identity, name, c {gainControl("micGain", t("Уровень микрофона гостя"), 200)} {gainControl("listenGain", t("Громкость звука у гостя"), 100)} - + void change({ audioProfile })} /> {pending ? connected ? t("Ожидаем подтверждение гостя…") : t("Переподключение…") : !connected || view?.offline ? t("Гость отключён. Показаны последние подтверждённые настройки.") : view?.state ? t("Настройки подтверждены гостем.") : t("Ожидаем состояние гостя…")} {view?.state?.noiseSuppressionState === "pending" && {t("Обработка применится при включении микрофона.")}} {view?.state && !["on", "off", "pending"].includes(view.state.noiseSuppressionState || "") && {t("Браузер гостя не подтвердил состояние шумоподавления.")}} {view?.state?.videoCodec && (view.state.videoEnabled === false || view.state.actualVideoCodec !== view.state.videoCodec) && {view.state.videoEnabled === false ? t("Кодек применится при включении камеры.") : t("Кодек камеры не подтверждён.")}} - {view?.state?.audioProfile === "raw" && {t("Для оригинального звука гостю нужны наушники.")}} {allowDeviceControl &&
{t("Устройства гостя")}
}
{t("Настройки")} diff --git a/apps/web/lib/guest-audio.ts b/apps/web/lib/guest-audio.ts index 675a8e9b..48a17b6d 100644 --- a/apps/web/lib/guest-audio.ts +++ b/apps/web/lib/guest-audio.ts @@ -4,9 +4,9 @@ export type GuestAudioProfile = "speech" | "raw"; type AudioChoices = { audioDeviceId?: string; audioProfile?: GuestAudioProfile }; type ProcessingOptions = Pick; -/** New microphones suppress speaker echo; an explicit original-sound choice is retained. */ +/** Processing is opt-in; retain an explicit saved speech profile. */ export function guestAudioProfile(value?: unknown): GuestAudioProfile { - return value === "raw" ? "raw" : "speech"; + return value === "speech" ? "speech" : "raw"; } export function guestAudioProcessingOptions(choices: AudioChoices): ProcessingOptions { diff --git a/apps/web/lib/i18n/workspaces.ts b/apps/web/lib/i18n/workspaces.ts index 2ce4ab32..6b2d4151 100644 --- a/apps/web/lib/i18n/workspaces.ts +++ b/apps/web/lib/i18n/workspaces.ts @@ -2,6 +2,9 @@ import type { Translator } from "@/lib/i18n"; export const workspacesMessages: Record = { + "Шумоподавление и эхо": ["Noise & echo suppression", "降噪与回声消除"], + "Включено — подавляет фоновый шум и эхо.": ["On — reduces background noise and echo.", "已开启 — 抑制背景噪音和回声。"], + "Выключено — оригинальный звук. Используйте наушники.": ["Off — original sound. Use headphones.", "已关闭 — 原始声音。请使用耳机。"], "Устройства": ["Devices", "设备"], "Устройства студии": ["Studio devices", "演播室设备"], "Пригласить": ["Invite", "邀请"], diff --git a/apps/web/tests/caster-media-menus.browser.cjs b/apps/web/tests/caster-media-menus.browser.cjs index 83caf07e..9852dc4b 100644 --- a/apps/web/tests/caster-media-menus.browser.cjs +++ b/apps/web/tests/caster-media-menus.browser.cjs @@ -56,7 +56,7 @@ const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); assert.equal(await evaluate(`document.querySelector(${JSON.stringify(trigger)}).getAttribute('aria-expanded')`),'true'); assert.deepEqual(await evaluate(`Array.from(document.querySelectorAll('${menu} select')).map(e=>e.getAttribute('aria-label'))`),fields,'each menu owns its device selectors'); assert.equal(await evaluate(`Array.from(document.querySelectorAll('${menu} button')).some(e=>e.textContent.trim()==='Закрыть')`),false); - if(trigger===mic){assert.equal(await evaluate(`(()=>{const r=document.createRange();r.selectNodeContents(document.querySelector('.guest-audio-processing-toggle strong'));return r.getClientRects().length})()`),1,'processing status stays on one line');assert.equal(await evaluate(`document.querySelector('${menu} [role="switch"]').getAttribute('aria-checked')`),'false');await click('.guest-audio-processing-toggle');assert.equal(await evaluate(`document.querySelector('${menu} [role="switch"]').getAttribute('aria-checked')`),'true');await click('.guest-audio-processing-toggle');} + if(trigger===mic){assert.deepEqual(await evaluate(`(()=>{const s=getComputedStyle(document.querySelector('${menu} [role="switch"]'));return [s.width,s.height]})()`),['44px','26px'],'processing switch keeps its full track size inside the real menu');assert.equal(await evaluate(`document.querySelector('${menu} [role="switch"]').checked`),false);await click(`${menu} [role="switch"]`);assert.equal(await evaluate(`document.querySelector('${menu} [role="switch"]').checked`),true);await click(`${menu} [role="switch"]`);} assert.equal(await evaluate(`(()=>{const r=document.querySelector('${menu}').getBoundingClientRect();return r.x>=0&&r.right<=innerWidth&&r.y>=0&&r.bottom<=innerHeight})()`),true,'menu fits viewport'); fs.writeFileSync(path.join(out,`${trigger===mic?'microphone':'camera'}-${theme}-${width}.png`),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64')); await click(trigger); await until(`!document.querySelector('${menu}')`,'same arrow closes'); diff --git a/apps/web/tests/guest-audio-profile-ui.cjs b/apps/web/tests/guest-audio-profile-ui.cjs index 4a369668..2364d6d6 100644 --- a/apps/web/tests/guest-audio-profile-ui.cjs +++ b/apps/web/tests/guest-audio-profile-ui.cjs @@ -61,7 +61,7 @@ global.window={isSecureContext:true};Object.defineProperty(global,'navigator',{c assert.deepEqual(nodes(checks).filter(node=>node.type==='button').map(node=>node.props.children),['Проверить микрофон','Проверить камеру','Проверить наушники / динамики'],'all three independent tests share one action row'); assert.deepEqual(nodes(nodes(tree).find(node=>node.props?.className?.includes('guest-av-join-actions'))).filter(node=>node.type==='button').map(node=>node.props.children),['Подключиться к студии'],'joining has its own action below the tests'); assert.equal(nodes(tree).some(node=>String(node.type?.name).includes('GuestAudioProfileSelector')),false,'prejoin never exposes the studio-controlled audio profile'); - button(tree,'Проверить микрофон').props.onClick();assert.equal(captured[0].video,false,'microphone-only test never requests the camera');assert.equal(captured[0].audio.noiseSuppression,true);assert.equal(captured[0].audio.echoCancellation,true); + button(tree,'Проверить микрофон').props.onClick();assert.equal(captured[0].video,false,'microphone-only test never requests the camera');assert.equal(captured[0].audio.noiseSuppression,false);assert.equal(captured[0].audio.echoCancellation,false); tree=render(props);button(tree,'Войти без микрофона').props.onClick();const stale=stream('old-mic');pending.shift()(stale);await settle();assert.equal(stale.track.stopped,1,'changed choices release an in-flight capture'); tree=render(props);assert.equal(button(tree,'Проверить микрофон').props.disabled,true,'disabled microphones cannot be enabled by testing'); button(tree,'Микрофон отключён ✓').props.onClick();tree=render(props);button(tree,'Проверить микрофон').props.onClick();const preview=stream('preview-mic');pending.shift()(preview);await settle(); @@ -70,10 +70,10 @@ assert.equal(nodes(tree).some(node=>String(node.type?.name).includes('GuestAudio tree=render(props);picker(tree).props.onChange({headphonesId:'chosen-output'});tree=render(props);button(tree,'Проверить наушники / динамики').props.onClick();assert.equal(tones[0].output,'chosen-output');assert.equal(captured.length,2,'speaker test never opens a capture device'); tree=render(props);picker(tree).props.onChange({headphonesId:'other-output'});await settle();assert.equal(tones[0].stops,1,'changing output cancels the prior tone'); tree=render(props);button(tree,'Проверить наушники / динамики').props.onClick();assert.equal(tones[1].output,'other-output'); - tree=render(props);button(tree,'Войти в конференцию').props.onClick();assert.equal(tones[1].stops,1,'joining stops test playback');const joinedStream=stream('new-mic');pending.shift()(joinedStream);await settle();assert.equal(joinedStream.track.stopped,1);assert.equal(joined[0].audioProfile,'speech');assert.equal(joined[0].videoCodec,'h264','fresh preflight prefers H264');reset(); + tree=render(props);button(tree,'Войти в конференцию').props.onClick();assert.equal(tones[1].stops,1,'joining stops test playback');const joinedStream=stream('new-mic');pending.shift()(joinedStream);await settle();assert.equal(joinedStream.track.stopped,1);assert.equal(joined[0].audioProfile,'raw');assert.equal(joined[0].videoCodec,'h264','fresh preflight prefers H264');reset(); const before=captured.length,savedRaw={height:720,micGain:80,listenGain:90,audioEnabled:false,videoEnabled:false,audioProfile:'raw',connectionRoute:'normal',videoCodec:'av1'}; tree=render({...props,initialChoices:savedRaw});runAutomatic();await settle();assert.equal(joined.at(-1).audioProfile,'raw');assert.equal(joined.at(-1).connectionRoute,'normal');assert.equal(joined.at(-1).videoCodec,'av1','accepted transfer preserves codec selection while camera is off');assert.equal(joined.at(-1).audioEnabled,false);assert.equal(joined.at(-1).videoEnabled,false);assert.equal(captured.length,before,'receive-only restoration does not open disabled devices');reset(); - tree=render({...props,initialChoices:{...savedRaw,audioProfile:undefined,videoCodec:'invalid-saved-codec'}});runAutomatic();await settle();assert.equal(joined.at(-1).audioProfile,'speech','missing saved profile enables speaker echo and noise protection');assert.equal(joined.at(-1).videoCodec,'h264','absent or invalid transfer codec uses the supported H264 default');reset(); + tree=render({...props,initialChoices:{...savedRaw,audioProfile:undefined,videoCodec:'invalid-saved-codec'}});runAutomatic();await settle();assert.equal(joined.at(-1).audioProfile,'raw','missing saved profile keeps echo and noise suppression off');assert.equal(joined.at(-1).videoCodec,'h264','absent or invalid transfer codec uses the supported H264 default');reset(); tree=render({...props,initialChoices:{...savedRaw,audioEnabled:true}});runAutomatic();assert.equal(captured.at(-1).video,false);assert.equal(captured.at(-1).audio.noiseSuppression,false,'hidden saved raw choices still govern microphone capture');const restored=stream('restored-raw');pending.shift()(restored);await settle();assert.equal(joined.at(-1).audioProfile,'raw');assert.equal(restored.track.stopped,1);reset(); tree=render(props);button(tree,'Проверить микрофон').props.onClick();const unmounted=stream('unmounted');reset();pending.shift()(unmounted);await settle();assert.equal(unmounted.track.stopped,1,'leaving releases a late permission response'); tree=render(props);assert.equal(picker(tree).props.autoRequestPermissions,false,'retained AV capture owns initial input permission request'); diff --git a/apps/web/tests/guest-audio-switch.browser.cjs b/apps/web/tests/guest-audio-switch.browser.cjs new file mode 100644 index 00000000..61d1be13 --- /dev/null +++ b/apps/web/tests/guest-audio-switch.browser.cjs @@ -0,0 +1,91 @@ +// Production components/CSS with an HTTP fixture for guest acknowledgements. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const web = path.resolve(__dirname, '..'); +const req = createRequire(path.join(web, 'package.json')); +const tooling = process.env.CASTLINK_QA_NODE_MODULES ? createRequire(path.join(process.env.CASTLINK_QA_NODE_MODULES, 'package.json')) : req; +const { chromium } = tooling('playwright'); +const out = process.env.CASTLINK_QA_OUT || fs.mkdtempSync(path.join(os.tmpdir(), 'castlink-audio-switch-')); +fs.mkdirSync(out, { recursive: true }); +let browser, server; +(async () => { + const entry = path.join(out, 'entry.tsx'); + fs.writeFileSync(entry, ` + import React, { useState } from 'react'; + import { createRoot } from 'react-dom/client'; + import { I18nProvider } from '@/components/I18nProvider'; + import { GuestAudioProfileSelector } from '@/components/GuestAudioProfileSelector'; + import { StudioGuestMediaControls } from '@/components/StudioGuestMediaControls'; + function App() { + const [profile, setProfile] = useState(undefined); + return
+

Микрофон

+

Кастер · Guest

+
; + } + createRoot(document.getElementById('root')).render(); + `); + await tooling('esbuild').build({ entryPoints: [entry], bundle: true, outfile: path.join(out, 'bundle.js'), jsx: 'automatic', alias: { '@': web }, nodePaths: [path.join(web, 'node_modules')], define: { 'process.env.NODE_ENV': '"production"' } }); + const styles = ['globals.css', 'graphite-workspace.css'].map(file => fs.readFileSync(path.join(web, 'app', file), 'utf8')).join('\n'); + const commands = []; + let profile = 'raw', result, fail = false; + server = http.createServer((request, response) => { + if (request.url.startsWith('/api/guest-media-control')) { + response.setHeader('content-type', 'application/json'); + if (request.method === 'POST') { + let body = ''; request.on('data', chunk => { body += chunk; }); + request.on('end', () => { + const command = JSON.parse(body); commands.push(command); + if (fail) { response.statusCode = 409; response.end(JSON.stringify({ error: 'Гость отключён' })); return; } + profile = command.patch.audioProfile; + result = { id: `cmd-${commands.length}`, ok: true }; + response.end(JSON.stringify({ command: { id: result.id, expiresAt: Date.now() + 60_000, patch: command.patch } })); + }); + } else response.end(JSON.stringify({ participantSid: 'PA-one', state: { audioProfile: profile, noiseSuppressionState: profile === 'speech' ? 'on' : 'off', connectionRoute: 'auto' }, result })); + } else if (request.url === '/bundle.js' || request.url === '/bundle.css') { + response.setHeader('content-type', request.url.endsWith('.css') ? 'text/css' : 'text/javascript'); + response.end(fs.readFileSync(path.join(out, request.url.slice(1)))); + } else { response.setHeader('content-type', 'text/html'); response.end(`
`); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + browser = await chromium.launch({ executablePath: process.env.CASTLINK_QA_CHROMIUM_BINARY, headless: true, args: ['--no-sandbox'] }); + const page = await browser.newPage(); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + for (const width of [360, 1280]) for (const theme of ['dark', 'light']) for (const locale of ['ru', 'en', 'zh-CN']) { + await page.setViewportSize({ width, height: 900 }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(({ theme }) => { document.documentElement.dataset.theme = theme; }, { theme }); + await page.evaluate(({ key, locale }) => { localStorage.setItem(key, locale); window.dispatchEvent(new StorageEvent('storage', { key, newValue: locale })); }, { key: require('./load-typescript.cjs').load('../lib/i18n.ts').LOCALE_STORAGE_KEY, locale }); + const guest = page.getByTestId('guest').getByRole('switch'); + const studio = page.getByTestId('studio').getByRole('switch'); + await studio.waitFor(); + await page.waitForFunction(() => !document.querySelector('[data-testid="studio"] [role="switch"]').disabled); + for (const input of [guest, studio]) { + const aligned = await input.evaluate(element => { const label = element.closest('label'), copy = label.firstElementChild.getBoundingClientRect(), toggle = element.getBoundingClientRect(); return toggle.left >= copy.right; }); + assert.equal(aligned, true, 'switch stays beside its label in both settings surfaces'); + } + assert.equal(await guest.isChecked(), false); + await guest.focus(); await page.keyboard.press('Space'); assert.equal(await guest.isChecked(), true); + await page.keyboard.press('Space'); assert.equal(await guest.isChecked(), false); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${width}/${theme}/${locale} horizontal overflow`); + if (locale === 'ru') await page.screenshot({ path: path.join(out, `${width}-${theme}.png`), fullPage: true }); + } + const studio = page.getByTestId('studio').getByRole('switch'); + await Promise.all([page.waitForResponse(response => response.request().method() === 'POST'), studio.click()]); + assert.deepEqual(commands.at(-1).patch, { audioProfile: 'speech' }); + assert.equal(commands.at(-1).participantSid, 'PA-one'); + await page.waitForFunction(() => { const el = document.querySelector('[data-testid="studio"] [role="switch"]'); return el.checked && !el.disabled; }); + await Promise.all([page.waitForResponse(response => response.request().method() === 'POST'), studio.click()]); + assert.deepEqual(commands.at(-1).patch, { audioProfile: 'raw' }); + await page.waitForFunction(() => { const el = document.querySelector('[data-testid="studio"] [role="switch"]'); return !el.checked && !el.disabled; }); + fail = true; + await Promise.all([page.waitForResponse(response => response.request().method() === 'POST'), studio.click()]); + await page.getByRole('alert').waitFor(); + assert.equal(await studio.isChecked(), false, 'failed command restores the confirmed switch state'); + assert.deepEqual(errors, []); + console.log(`PASS: keyboard, default off, studio on/off/ACK/error, 12 responsive theme/locale cases; screenshots: ${out}`); +})().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => { await browser?.close(); server?.close(); }); diff --git a/apps/web/tests/guest-audio-switch.cjs b/apps/web/tests/guest-audio-switch.cjs new file mode 100644 index 00000000..51018244 --- /dev/null +++ b/apps/web/tests/guest-audio-switch.cjs @@ -0,0 +1,31 @@ +const assert = require('node:assert/strict'); +const { load } = require('./load-typescript.cjs'); +const audio = load('../lib/guest-audio.ts'); +const { GuestAudioProfileSelector } = load('../components/GuestAudioProfileSelector.tsx', { + react: { useId: () => 'processing-test' }, + '@/components/I18nProvider': { useI18n: () => ({ t: value => value }) }, +}); +const nodes = node => !node || typeof node !== 'object' ? [] : Array.isArray(node) ? node.flatMap(nodes) : [node, ...nodes(node.props?.children)]; +const render = props => nodes(GuestAudioProfileSelector({ onChange() {}, ...props })); +const control = props => render(props).find(node => node.props?.role === 'switch'); + +const initial = control({}); +assert.ok(initial, 'microphone processing is exposed as a single switch'); +assert.equal(initial.props.checked, false, 'new microphones start without noise or echo suppression'); +assert.deepEqual(audio.guestAudioCaptureOptions({}), { autoGainControl: false, echoCancellation: false, noiseSuppression: false, voiceIsolation: false }); +for (const [value, checked] of [[undefined, false], ['raw', false], ['speech', true]]) { + let selected; + const input = control({ value, onChange: profile => { selected = profile; } }); + assert.equal(input.props.checked, checked, 'switch reflects saved choices'); + input.props.onChange({ target: { checked: !checked } }); + const capture = audio.guestAudioCaptureOptions({ audioProfile: selected }); + assert.equal(capture.noiseSuppression, !checked); + assert.equal(capture.echoCancellation, !checked, 'one switch changes both processing flags'); + assert.equal(capture.autoGainControl, false); +} +assert.equal(control({ disabled: true }).props.disabled, true); +assert.equal(control({ busy: true }).props.disabled, true); +assert.ok(render({ busy: true }).some(node => node.props?.role === 'status'), 'pending change has an announced status'); +assert.equal(render({}).filter(node => node.props?.role === 'switch').length, 1); +assert.equal(render({}).some(node => node.type === 'select'), false); +console.log('PASS: one accessible processing switch defaults off, restores saved choices and controls noise/echo together'); diff --git a/apps/web/tests/guest-audio.cjs b/apps/web/tests/guest-audio.cjs index 4057ecff..abe9422b 100644 --- a/apps/web/tests/guest-audio.cjs +++ b/apps/web/tests/guest-audio.cjs @@ -10,13 +10,13 @@ const { guestCaptureConstraints } = require('../lib/guest-media.ts'); const speech = { audioDeviceId:'chosen-mic', audioProfile:'speech', height:720, micGain:100, listenGain:100 }; const raw = { ...speech, audioProfile:'raw' }; const processing = enabled => ({ autoGainControl:false, echoCancellation:enabled, noiseSuppression:enabled, voiceIsolation:false }); -assert.equal(guestAudioProfile(undefined), 'speech'); -assert.equal(guestAudioProfile('invalid-saved-mode'), 'speech'); +assert.equal(guestAudioProfile(undefined), 'raw'); +assert.equal(guestAudioProfile('invalid-saved-mode'), 'raw'); assert.equal(guestAudioProfile('raw'), 'raw'); assert.equal(guestAudioProfile('speech'), 'speech', 'explicit saved studio choice remains speech'); assert.deepEqual(guestAudioCaptureOptions(speech), { ...processing(true), deviceId:{exact:'chosen-mic'} }); assert.deepEqual(guestAudioCaptureOptions(raw), { ...processing(false), deviceId:{exact:'chosen-mic'} }); -assert.deepEqual(guestAudioProcessingOptions({}), processing(true), 'a new microphone suppresses speaker echo and noise'); +assert.deepEqual(guestAudioProcessingOptions({}), processing(false), 'a new microphone starts with echo and noise suppression off'); assert.deepEqual(guestCaptureConstraints(raw).audio, guestAudioCaptureOptions(raw), 'preview and live request identical constraints'); assert.deepEqual(guestAudioPublishOptions(speech), { dtx:true, red:true, forceStereo:false, audioPreset:{maxBitrate:48000} }); assert.deepEqual(guestAudioPublishOptions(raw, {channelCount:1}), { dtx:false, red:true, forceStereo:false, audioPreset:{maxBitrate:96000} }); diff --git a/apps/web/tests/guest-media-controls-ui.cjs b/apps/web/tests/guest-media-controls-ui.cjs index d0b3d7ad..83ee7d76 100644 --- a/apps/web/tests/guest-media-controls-ui.cjs +++ b/apps/web/tests/guest-media-controls-ui.cjs @@ -9,13 +9,15 @@ const react={ useEffect(effect,deps){const scope=context,i=scope.cursor++,old=scope.hooks[i];if(!old||deps.some((value,j)=>value!==old.deps[j]))scope.effects.push(()=>{old?.cleanup?.();scope.hooks[i]={deps,cleanup:effect()};});}, }; function renderer(component){const scope={cursor:0,hooks:[],setters:[],effects:[]};return{render(props){context=scope;scope.cursor=0;const tree=component(props);while(scope.effects.length)scope.effects.shift()();return tree;},unmount(){for(const hook of scope.hooks)hook?.cleanup?.();}};} +const {GuestAudioProfileSelector}=require('./load-typescript.cjs').load('../components/GuestAudioProfileSelector.tsx', {react,'@/components/I18nProvider':require('./i18n-fixture.cjs')}); const originalLoad=Module._load; -Module._load=function(name,...rest){if(name==='react')return react;if(name==='react/jsx-runtime')return{jsx:(type,props)=>({type,props}),jsxs:(type,props)=>({type,props})};return originalLoad.call(this,name,...rest);}; +Module._load=function(name,...rest){if(name==='./GuestAudioProfileSelector')return{GuestAudioProfileSelector};if(name==='react')return react;if(name==='react/jsx-runtime')return{jsx:(type,props)=>({type,props}),jsxs:(type,props)=>({type,props})};return originalLoad.call(this,name,...rest);}; const {GuestCameraCaptureControls}=require('../components/GuestCameraCaptureControls.tsx'); const {StudioGuestMediaControls}=require('../components/StudioGuestMediaControls.tsx'); const {StudioGuestMediaPanel}=require('../components/StudioGuestMediaPanel.tsx'); Module._load=originalLoad; const nodes=node=>!node||typeof node!=='object'?[]:Array.isArray(node)?node.flatMap(nodes):[node,...nodes(node.props?.children)]; +const processingControl=tree=>nodes(GuestAudioProfileSelector(nodes(tree).find(node=>node.type===GuestAudioProfileSelector).props)).find(node=>node.props?.role==='switch'); const settle=()=>new Promise(resolve=>setImmediate(resolve)); const timers=new Set(); global.setInterval=fn=>{timers.add(fn);return fn;};global.clearInterval=fn=>timers.delete(fn); @@ -45,18 +47,28 @@ const pulse=async()=>{for(const timer of timers)timer();await settle();}; await pulse();controlsTree=controls.render(child.props);nodes(controlsTree).find(node=>node.type==='input'&&node.props['aria-label']==='Громкость звука у гостя').props.onKeyUp({key:'ArrowLeft',currentTarget:{value:'40'}});await settle();assert.deepEqual(commands.at(-1).patch,{listenGain:40}); await pulse();controlsTree=controls.render(child.props);nodes(controlsTree).find(node=>node.type==='button'&&node.props.children==='Остановить показ экрана гостя').props.onClick();await settle();assert.deepEqual(commands.at(-1).patch,{screenShareEnabled:false}); await pulse();controlsTree=controls.render(child.props); - const microphone=nodes(controlsTree).find(node=>node.type==='select'&&node.props['aria-label']==='Режим микрофона Guest'); + const microphone=processingControl(controlsTree); const route=nodes(controlsTree).find(node=>node.type==='select'&&node.props['aria-label']==='Маршрут подключения Guest'); - assert.equal(microphone.props.value,'speech');assert.equal(microphone.props.disabled,false); + assert.equal(microphone.props.checked,true);assert.equal(microphone.props.disabled,false); assert.equal(nodes(route).find(node=>node.props?.value==='relay').props.disabled,true,'TURN selection disabled without server capability'); const codec=nodes(controlsTree).find(node=>node.type==='select'&&node.props['aria-label']==='Видеокодек Guest'); assert.equal(codec.props.value,'vp8');assert.equal(codec.props.disabled,false);assert.equal(nodes(codec).find(node=>node.props?.value==='av1').props.disabled,true,'unsupported sender codec is disabled'); codec.props.onChange({target:{value:'h264'}});await settle();controlsTree=controls.render(child.props); assert.deepEqual(commands.at(-1).patch,{videoCodec:'h264'});assert.equal(nodes(controlsTree).find(node=>node.type==='select'&&node.props['aria-label']==='Видеокодек Guest').props.value,'vp8','request never impersonates actual confirmed codec'); - microphone.props.onChange({target:{value:'raw'}});await settle();controlsTree=controls.render(child.props); + await pulse();controlsTree=controls.render(child.props); + processingControl(controlsTree).props.onChange({target:{checked:false}});await settle();controlsTree=controls.render(child.props); assert.deepEqual(commands.at(-1).patch,{audioProfile:'raw'});assert.equal(commands.at(-1).participantSid,'PA-1'); - assert.equal(nodes(controlsTree).find(node=>node.type==='select'&&node.props['aria-label']==='Режим микрофона Guest').props.value,'speech','accepted command retains last confirmed displayed value'); + assert.equal(processingControl(controlsTree).props.checked,true,'accepted command retains last confirmed displayed value'); + assert.equal(processingControl(controlsTree).props.disabled,true,'pending command locks the switch until guest confirmation'); assert.ok(nodes(controlsTree).some(node=>node.props?.role==='status'&&String(node.props.children).includes('Ожидаем подтверждение'))); + view={...view,state:{...view.state,audioProfile:'raw'},result:{id:'command-one',ok:true}}; + await pulse();controlsTree=controls.render(child.props); + assert.equal(processingControl(controlsTree).props.checked,false,'guest ACK updates the studio switch'); + processingControl(controlsTree).props.onChange({target:{checked:true}});await settle();controlsTree=controls.render(child.props); + assert.deepEqual(commands.at(-1).patch,{audioProfile:'speech'},'studio can also enable both processing flags'); + assert.equal(commands.at(-1).identity,'guest-one'); + assert.equal(commands.at(-1).participantSid,'PA-1'); + assert.equal(processingControl(controlsTree).props.checked,false,'enable waits for guest ACK too'); props={...props,guests:[]};tree=panel.render(props);child=nodes(tree).find(node=>node.type===StudioGuestMediaControls);assert.ok(child,'only pending controls survive guest video-card removal');assert.equal(child.props.connected,false); controlsTree=controls.render(child.props);assert.ok(nodes(controlsTree).some(node=>node.props?.role==='status'&&node.props.children==='Переподключение…')); view={offline:true,state:{audioProfile:'speech',connectionRoute:'auto'},result:{id:'command-one',ok:false,error:'TURN join failed'},connectionRoutes:{relay:{configured:false}}}; diff --git a/apps/web/tests/guest-media.cjs b/apps/web/tests/guest-media.cjs index 43053bb4..05c1d654 100644 --- a/apps/web/tests/guest-media.cjs +++ b/apps/web/tests/guest-media.cjs @@ -9,7 +9,7 @@ assert.deepEqual(guestCameraPreferences({cameraResolution:'4k',cameraFrameRate:' const base = { height: 720, micGain: 100, listenGain: 100, audioDeviceId: 'mic', videoDeviceId: 'cam' }; assert.equal(guestCaptureConstraints({...base, audioEnabled:false, videoEnabled:false}), null, 'receive-only entry must not call getUserMedia'); const audioOnly = guestCaptureConstraints({...base, videoEnabled:false}); -assert.equal(audioOnly.video, false); assert.deepEqual(audioOnly.audio, {deviceId:{exact:'mic'}, autoGainControl:false, echoCancellation:true, noiseSuppression:true, voiceIsolation:false}); +assert.equal(audioOnly.video, false); assert.deepEqual(audioOnly.audio, {deviceId:{exact:'mic'}, autoGainControl:false, echoCancellation:false, noiseSuppression:false, voiceIsolation:false}); const videoOnly = guestCaptureConstraints({...base, audioEnabled:false}); assert.equal(videoOnly.audio, false); assert.equal(videoOnly.video.deviceId.exact, 'cam'); assert.equal(videoOnly.video.width, undefined, 'guest capture must not force a 16:9 width/crop'); diff --git a/apps/web/tests/guest-noise-control.cjs b/apps/web/tests/guest-noise-control.cjs index 08e2a23b..74155bc2 100644 --- a/apps/web/tests/guest-noise-control.cjs +++ b/apps/web/tests/guest-noise-control.cjs @@ -1,12 +1,7 @@ -require('./i18n-fixture.cjs'); const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const ts = require('typescript'); -require.extensions['.tsx'] = (module, file) => module._compile(ts.transpileModule(fs.readFileSync(file, 'utf8'), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }, -}).outputText, file); -const { GuestNoiseSuppressionControl } = require('../components/GuestNoiseSuppressionControl.tsx'); -const nodes = node => !node || typeof node !== 'object' ? [] : Array.isArray(node) ? node.flatMap(nodes) : [node, ...nodes(node.props?.children)]; +const { load } = require('./load-typescript.cjs'); +const { GuestNoiseSuppressionControl } = load('../components/GuestNoiseSuppressionControl.tsx', {react:{useId:()=> 'noise-test'},'@/components/I18nProvider':require('./i18n-fixture.cjs')}); +const nodes = node => !node || typeof node !== 'object' ? [] : Array.isArray(node) ? node.flatMap(nodes) : [node, ...nodes(typeof node.type === 'function' ? node.type(node.props) : node.props?.children)]; const changes = []; const props = { profile: 'raw', state: 'off', confirmed: true, disabled: false, busy: false, onChange: value => changes.push(value) }; let tree = GuestNoiseSuppressionControl(props); @@ -32,9 +27,9 @@ for (const patch of [{ disabled: true }, { busy: true }]) { } tree = GuestNoiseSuppressionControl({ ...props, asButton: true }); toggle = nodes(tree).find(node => node.props?.role === 'switch'); -assert.equal(toggle.type, 'button'); assert.equal(toggle.props['aria-checked'], false); -toggle.props.onClick(); assert.equal(changes.at(-1), 'speech'); +assert.equal(toggle.type, 'input'); assert.equal(toggle.props.checked, false); +toggle.props.onChange({target:{checked:true}}); assert.equal(changes.at(-1), 'speech'); tree = GuestNoiseSuppressionControl({ ...props, asButton: true, profile: 'speech', state: 'pending' }); -assert.equal(nodes(tree).find(node => node.props?.role === 'switch').props['aria-checked'], true); +assert.equal(nodes(tree).find(node => node.props?.role === 'switch').props.checked, true); assert.ok(nodes(tree).some(node => node.props?.role === 'status' && node.props.children === 'Обработка применится при включении микрофона.')); console.log('PASS: caster can select speech/raw, actual/pending/unconfirmed processing and busy/disabled states'); diff --git a/apps/web/tests/guest-workspace-media.cjs b/apps/web/tests/guest-workspace-media.cjs index 8ce9a49b..7004063b 100644 --- a/apps/web/tests/guest-workspace-media.cjs +++ b/apps/web/tests/guest-workspace-media.cjs @@ -95,6 +95,7 @@ const mocked={ const params=new URLSearchParams(); const originalLoad=Module._load; Module._load=function(name,...rest){ + if(name.endsWith('.module.css'))return {}; if(name==='react')return react; if(name==='react/jsx-runtime')return{jsx:(type,props)=>({type,props}),jsxs:(type,props)=>({type,props})}; if(name==='livekit-client')return sdk; diff --git a/apps/web/tests/private-talkback-controls.cjs b/apps/web/tests/private-talkback-controls.cjs index 1a0e3711..01b33962 100644 --- a/apps/web/tests/private-talkback-controls.cjs +++ b/apps/web/tests/private-talkback-controls.cjs @@ -13,13 +13,14 @@ function renderer(fn){const scope={cursor:0,hooks:[],effects:[]};return{render(p const nodes=node=>!node||typeof node!=='object'?[]:Array.isArray(node)?node.flatMap(nodes):[node,...nodes(node.props?.children)]; const settle=async()=>{for(let i=0;i<8;i++)await new Promise(resolve=>setImmediate(resolve));}; const calls=[],rooms=[],timers=new Set(); +const audio = require('./load-typescript.cjs').load('../lib/guest-audio.ts'); const RoomEvent=new Proxy({}, {get:(_,key)=>key}),Track={Source:{Microphone:'microphone',Camera:'camera'},Kind:{Audio:'audio',Video:'video'},sourceToProto:()=>2}; class Room extends EventEmitter{constructor(){super();rooms.push(this);this.state='connected';this.remoteParticipants=new Map();this.localParticipant={identity:'local',permissions:{canPublish:true,canPublishSources:[]},getTrackPublication:()=>undefined,setTrackSubscriptionPermissions:(...args)=>calls.push(['permissions',this,...args]),publishTrack:async track=>calls.push(['publish',this,track]),unpublishTrack:async track=>calls.push(['unpublish',this,track]),setMicrophoneEnabled:async enabled=>calls.push(['main-mic',enabled])};}async connect(url,token,options){calls.push(['connect',this,token,options]);}async disconnect(){this.state='disconnected';calls.push(['disconnect',this]);this.emit(RoomEvent.Disconnected);}getActiveDevice(){return 'headphones';}async switchActiveDevice(kind,id){calls.push(['output',this,kind,id]);}} -const createLocalAudioTrack=async()=>{calls.push(['capture']);return{stop(){calls.push(['stop-track']);},mute(){calls.push(['private-mute']);},unmute(){calls.push(['private-unmute']);}};}; +const createLocalAudioTrack=async options=>{calls.push(['capture',options]);return{stop(){calls.push(['stop-track']);},mute(){calls.push(['private-mute']);},unmute(){calls.push(['private-unmute']);}};}; const requests=[];let active=true,restoreMicrophone=true; global.fetch=async(url,options={})=>{const body=options.body?JSON.parse(options.body):undefined;requests.push({url,method:options.method||'GET',body,headers:options.headers});if(body?.action==='reply-begin'){rooms[0].localParticipant.permissions.canPublish=false;rooms[0].emit(RoomEvent.ParticipantPermissionsChanged);}if(body?.action==='reply-end'){rooms[0].localParticipant.permissions.canPublish=true;rooms[0].emit(RoomEvent.ParticipantPermissionsChanged);}return{ok:true,json:async()=>body?.action==='reply-begin'?{replyId:'reply'}:body?.action==='reply-end'?{restoreMicrophone}:options.method==='DELETE'?{}:{active,sessionId:'private-session',token:'private-token',staffName:'Studio',restoreMicrophone:true}};}; const windowEvents=new EventEmitter(),documentEvents=new EventEmitter();global.navigator={mediaDevices:{enumerateDevices:async()=>[]}};global.window={addEventListener:(name,handler)=>windowEvents.on(name,handler),removeEventListener:(name,handler)=>windowEvents.off(name,handler)};global.document={addEventListener:(name,handler)=>documentEvents.on(name,handler),removeEventListener:(name,handler)=>documentEvents.off(name,handler),visibilityState:'visible'}; -const mocked={'@/lib/guest-audio-gain':{setGuestMicrophoneGain:async(room,gain)=>{calls.push(['private-gain',room,gain]);return true;}},react,'react/jsx-runtime':{jsx:(type,props)=>({type,props}),jsxs:(type,props)=>({type,props})},'livekit-client':{Room,RoomEvent,Track,createLocalAudioTrack},'@/components/I18nProvider':{useI18n:()=>({t,locale:'ru'})},'@/lib/i18n/workspaces':{translateWorkspaceMessage:(_,value)=>value},'@/lib/video-rotation':{useVideoRotation:noop},'@/components/AudioOutputSelect':{AudioOutputSelect:component},'@/components/ScreenShareButton':{ScreenShareButton:component},'@/lib/livekit-url':{getLiveKitUrl:()=> 'wss://test'},'@/lib/participant-role':{resolveParticipantRole:()=> 'guest'},'@/lib/private-talkback-audio':{bindPrivateTalkbackAudio:()=>{calls.push(['bind-private-playback']);return()=>calls.push(['dispose-private-playback']);}},'@/lib/guest-audio':{guestAudioCaptureOptions:choices=>({deviceId:choices.audioDeviceId}),setGuestMicrophoneEnabled:async(room,enabled,_choices,isCurrent=()=>true)=>{if(isCurrent()){await room.localParticipant.setMicrophoneEnabled(enabled);return true;}return false;}}}; +const mocked={'@/lib/guest-audio-gain':{setGuestMicrophoneGain:async(room,gain)=>{calls.push(['private-gain',room,gain]);return true;}},react,'react/jsx-runtime':{jsx:(type,props)=>({type,props}),jsxs:(type,props)=>({type,props})},'livekit-client':{Room,RoomEvent,Track,createLocalAudioTrack},'@/components/I18nProvider':{useI18n:()=>({t,locale:'ru'})},'@/lib/i18n/workspaces':{translateWorkspaceMessage:(_,value)=>value},'@/lib/video-rotation':{useVideoRotation:noop},'@/components/AudioOutputSelect':{AudioOutputSelect:component},'@/components/ScreenShareButton':{ScreenShareButton:component},'@/lib/livekit-url':{getLiveKitUrl:()=> 'wss://test'},'@/lib/participant-role':{resolveParticipantRole:()=> 'guest'},'@/lib/private-talkback-audio':{bindPrivateTalkbackAudio:()=>{calls.push(['bind-private-playback']);return()=>calls.push(['dispose-private-playback']);}},'@/lib/guest-audio':{guestAudioCaptureOptions:audio.guestAudioCaptureOptions,setGuestMicrophoneEnabled:async(room,enabled,_choices,isCurrent=()=>true)=>{if(isCurrent()){if(enabled)calls.push(['restore-processing',audio.guestAudioCaptureOptions(_choices())]);await room.localParticipant.setMicrophoneEnabled(enabled);return true;}return false;}}}; const original=Module._load;Module._load=function(name,...args){if(name in mocked)return mocked[name];return original.call(this,name,...args);}; for(const ext of ['.ts','.tsx'])Module._extensions[ext]=(module,file)=>module._compile(ts.transpileModule(fs.readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText,file); mocked['@/lib/talkback-policy']=require('../lib/talkback-policy.ts');mocked['@/lib/private-talkback-reply']=require('../lib/private-talkback-reply.ts'); @@ -75,9 +76,10 @@ const originalInterval=global.setInterval,originalClear=global.clearInterval;glo assert.equal(calls.some(c=>c[0]==='main-mic'||c[0]==='capture'),false,'listening to a private session leaves guest program microphone untouched');assert.deepEqual(busyChanges,[],'idle intercom does not lock normal guest controls'); const press=()=>{tree=guest.render(guestProps);const button=nodes(tree).find(node=>node.type==='button');assert.equal(button.props.onClick,undefined,'reply is never a latched click toggle');button.props.onPointerDown({preventDefault(){},pointerId:1,currentTarget:{setPointerCapture(){}}});return button;}; let button=press();await settle(); + assert.equal(calls.find(c=>c[0]==='capture')[1].noiseSuppression,false,'missing private reply profile keeps suppression off');assert.equal(calls.find(c=>c[0]==='capture')[1].echoCancellation,false); const began=requests.findIndex(req=>req.body?.action==='reply-begin');assert.ok(began>=0,'held reply obtains server gate ACK');assert.ok(calls.some(c=>c[0]==='main-mic'&&c[1]===false)); assert.ok(calls.findIndex(c=>c[0]==='private-mute')c[0]==='publish'));assert.equal(calls.find(c=>c[0]==='private-gain')[2],35); - button.props.onPointerUp();await settle();assert.ok(requests.some(req=>req.body?.action==='reply-end'&&req.body.replyId==='reply'));assert.ok(calls.some(c=>c[0]==='main-mic'&&c[1]===true));assert.deepEqual(busyChanges,[true,false]); + button.props.onPointerUp();await settle();assert.ok(requests.some(req=>req.body?.action==='reply-end'&&req.body.replyId==='reply'));assert.ok(calls.some(c=>c[0]==='main-mic'&&c[1]===true));assert.deepEqual(busyChanges,[true,false]);assert.equal(calls.find(c=>c[0]==='restore-processing')[1].noiseSuppression,false,'private reply restoration retains the raw default');assert.equal(calls.find(c=>c[0]==='restore-processing')[1].echoCancellation,false); for(const event of ['onPointerOut','onPointerCancel','onLostPointerCapture','onBlur']){button=press();await settle();button.props[event]();await settle();assert.equal(busyChanges.at(-1),false,event+' releases the PTT gate');} button=press();await settle();button.props.onPointerMove({clientX:101,clientY:20,currentTarget:{getBoundingClientRect:()=>({left:0,right:100,top:0,bottom:50})}});await settle();assert.equal(busyChanges.at(-1),false,'dragging a captured pointer outside the PTT button releases it'); button=press();await settle();windowEvents.emit('blur');await settle();assert.equal(busyChanges.at(-1),false,'window blur releases the held reply'); diff --git a/apps/web/tests/run-ci.cjs b/apps/web/tests/run-ci.cjs index 98612c9a..381556f5 100644 --- a/apps/web/tests/run-ci.cjs +++ b/apps/web/tests/run-ci.cjs @@ -2,6 +2,7 @@ const { spawnSync } = require("node:child_process"); const path = require("node:path"); const tests = [ + "guest-audio-switch.cjs", "guest-noise-control.cjs", "client-errors.cjs", "client-errors-routing.cjs", diff --git a/distribution/release-tracks.json b/distribution/release-tracks.json index 94938c84..cf7280e3 100644 --- a/distribution/release-tracks.json +++ b/distribution/release-tracks.json @@ -3,7 +3,7 @@ "repository": "resokol/castlink", "tracks": { "client": { "version": "1.0.0", "branch": "main", "tagPrefix": "client-v" }, - "server-setup": { "version": "1.0.2", "branch": "main", "tagPrefix": "server-setup-v" }, - "linux": { "version": "1.0.2", "branch": "main", "tagPrefix": "linux-v" } + "server-setup": { "version": "1.0.3", "branch": "main", "tagPrefix": "server-setup-v" }, + "linux": { "version": "1.0.3", "branch": "main", "tagPrefix": "linux-v" } } } diff --git a/docs/manual/FILE_MAP_RU.md b/docs/manual/FILE_MAP_RU.md index ae9ef9a5..bb0d1e49 100644 --- a/docs/manual/FILE_MAP_RU.md +++ b/docs/manual/FILE_MAP_RU.md @@ -6,7 +6,7 @@ Прямые импорты извлечены статически. Динамические загрузки с вычисляемым именем, HTTP, LiveKit events и SQL-связи не выводятся автоматически и разобраны в главе 03. Экспорт — доступный другим модулям символ, а не обязательно публичный HTTP API. Наличие теста в каталоге не означает, что он запускался. -Всего файлов в Git index: **1964**. Каждый файл ниже имеет отдельную запись. +Всего файлов в Git index: **1967**. Каждый файл ниже имеет отдельную запись. ## Корень · 18 файлов @@ -5208,7 +5208,7 @@ React-страница /watch. Интерфейс и связи с компон Используют: [`apps/web/tests/design-migration.browser.cjs`](../../apps/web/tests/design-migration.browser.cjs), [`apps/web/tests/fixtures/image-correction-pipeline.tsx`](../../apps/web/tests/fixtures/image-correction-pipeline.tsx), [`apps/web/tests/watch-transfer.cjs`](../../apps/web/tests/watch-transfer.cjs). -## apps/web/components · 128 файлов +## apps/web/components · 129 файлов ### apps/web/components/AccessGate.tsx @@ -5486,6 +5486,14 @@ React-страница /watch. Интерфейс и связи с компон Используют: [`apps/web/components/RoomControlConsole.tsx`](../../apps/web/components/RoomControlConsole.tsx), [`apps/web/components/StudioWorkspace.tsx`](../../apps/web/components/StudioWorkspace.tsx), [`apps/web/tests/studio-caster-color-controls.browser.cjs`](../../apps/web/tests/studio-caster-color-controls.browser.cjs). +### apps/web/components/GuestAudioProfileSelector.module.css + +[`apps/web/components/GuestAudioProfileSelector.module.css`](../../apps/web/components/GuestAudioProfileSelector.module.css) + +Локальные стили интерфейса; цвета, размеры и адаптивная раскладка соответствующего компонента. + +Используют: [`apps/web/components/GuestAudioProfileSelector.tsx`](../../apps/web/components/GuestAudioProfileSelector.tsx). + ### apps/web/components/GuestAudioProfileSelector.tsx [`apps/web/components/GuestAudioProfileSelector.tsx`](../../apps/web/components/GuestAudioProfileSelector.tsx) @@ -5494,7 +5502,11 @@ React-страница /watch. Интерфейс и связи с компон Экспортируемые символы: `GuestAudioProfileSelector`. -Читает/импортирует: [`apps/web/components/I18nProvider.tsx`](../../apps/web/components/I18nProvider.tsx), [`apps/web/lib/guest-audio.ts`](../../apps/web/lib/guest-audio.ts). +Читает/импортирует: [`apps/web/components/GuestAudioProfileSelector.module.css`](../../apps/web/components/GuestAudioProfileSelector.module.css), [`apps/web/components/I18nProvider.tsx`](../../apps/web/components/I18nProvider.tsx), [`apps/web/lib/guest-audio.ts`](../../apps/web/lib/guest-audio.ts). + +Библиотеки/Node: `react`. + +Используют: [`apps/web/components/GuestNoiseSuppressionControl.tsx`](../../apps/web/components/GuestNoiseSuppressionControl.tsx), [`apps/web/components/StudioGuestMediaControls.tsx`](../../apps/web/components/StudioGuestMediaControls.tsx), [`apps/web/tests/guest-audio-switch.browser.cjs`](../../apps/web/tests/guest-audio-switch.browser.cjs). ### apps/web/components/GuestAvCheck.tsx @@ -5678,9 +5690,9 @@ React-страница /watch. Интерфейс и связи с компон Экспортируемые символы: `GuestNoiseSuppressionControl`. -Читает/импортирует: [`apps/web/components/I18nProvider.tsx`](../../apps/web/components/I18nProvider.tsx), [`apps/web/lib/guest-audio.ts`](../../apps/web/lib/guest-audio.ts). +Читает/импортирует: [`apps/web/components/GuestAudioProfileSelector.tsx`](../../apps/web/components/GuestAudioProfileSelector.tsx), [`apps/web/components/I18nProvider.tsx`](../../apps/web/components/I18nProvider.tsx), [`apps/web/lib/guest-audio.ts`](../../apps/web/lib/guest-audio.ts). -Используют: [`apps/web/components/GuestWorkspace.tsx`](../../apps/web/components/GuestWorkspace.tsx), [`apps/web/tests/caster-media-menus.browser.cjs`](../../apps/web/tests/caster-media-menus.browser.cjs), [`apps/web/tests/guest-noise-control.cjs`](../../apps/web/tests/guest-noise-control.cjs). +Используют: [`apps/web/components/GuestWorkspace.tsx`](../../apps/web/components/GuestWorkspace.tsx), [`apps/web/tests/caster-media-menus.browser.cjs`](../../apps/web/tests/caster-media-menus.browser.cjs). ### apps/web/components/GuestParticipantsTable.tsx @@ -5866,7 +5878,7 @@ React-страница /watch. Интерфейс и связи с компон Библиотеки/Node: `react`. -Используют: [`apps/web/app/diagnostics/page.tsx`](../../apps/web/app/diagnostics/page.tsx), [`apps/web/app/error.tsx`](../../apps/web/app/error.tsx), [`apps/web/app/join/page.tsx`](../../apps/web/app/join/page.tsx), [`apps/web/app/monitor/page.tsx`](../../apps/web/app/monitor/page.tsx), [`apps/web/app/not-found.tsx`](../../apps/web/app/not-found.tsx), [`apps/web/app/output/[identity]/page.tsx`](../../apps/web/app/output/%5Bidentity%5D/page.tsx), [`apps/web/app/output/slot/[slotId]/page.tsx`](../../apps/web/app/output/slot/%5BslotId%5D/page.tsx), [`apps/web/app/remote-pc/publisher/page.tsx`](../../apps/web/app/remote-pc/publisher/page.tsx), [`apps/web/app/room/page.tsx`](../../apps/web/app/room/page.tsx), [`apps/web/app/servers/page.tsx`](../../apps/web/app/servers/page.tsx), [`apps/web/app/setup/page.tsx`](../../apps/web/app/setup/page.tsx), [`apps/web/app/studios/page.tsx`](../../apps/web/app/studios/page.tsx), [`apps/web/app/super-admin/page.tsx`](../../apps/web/app/super-admin/page.tsx), [`apps/web/app/transfer/page.tsx`](../../apps/web/app/transfer/page.tsx), [`apps/web/app/watch/page.tsx`](../../apps/web/app/watch/page.tsx), [`apps/web/components/AccessGate.tsx`](../../apps/web/components/AccessGate.tsx), [`apps/web/components/AdminInviteButton.tsx`](../../apps/web/components/AdminInviteButton.tsx), [`apps/web/components/AppChrome.tsx`](../../apps/web/components/AppChrome.tsx), [`apps/web/components/AppUpdateNotice.tsx`](../../apps/web/components/AppUpdateNotice.tsx), [`apps/web/components/AudioMeter.tsx`](../../apps/web/components/AudioMeter.tsx), [`apps/web/components/AudioOutputSelect.tsx`](../../apps/web/components/AudioOutputSelect.tsx), [`apps/web/components/CasterSlotsPanel.tsx`](../../apps/web/components/CasterSlotsPanel.tsx), [`apps/web/components/ChatEmojis.tsx`](../../apps/web/components/ChatEmojis.tsx), [`apps/web/components/CoPresentationPanel.tsx`](../../apps/web/components/CoPresentationPanel.tsx), [`apps/web/components/ConferenceScene.tsx`](../../apps/web/components/ConferenceScene.tsx), [`apps/web/components/DesignPreview.tsx`](../../apps/web/components/DesignPreview.tsx), [`apps/web/components/EmergencyReservePanel.tsx`](../../apps/web/components/EmergencyReservePanel.tsx), [`apps/web/components/EntryShell.tsx`](../../apps/web/components/EntryShell.tsx), [`apps/web/components/GuestAccessGate.tsx`](../../apps/web/components/GuestAccessGate.tsx), [`apps/web/components/GuestAudioProfileSelector.tsx`](../../apps/web/components/GuestAudioProfileSelector.tsx), [`apps/web/components/GuestAvCheck.tsx`](../../apps/web/components/GuestAvCheck.tsx), [`apps/web/components/GuestCameraButton.tsx`](../../apps/web/components/GuestCameraButton.tsx), [`apps/web/components/GuestCameraCaptureControls.tsx`](../../apps/web/components/GuestCameraCaptureControls.tsx), [`apps/web/components/GuestCameraResolutionControl.tsx`](../../apps/web/components/GuestCameraResolutionControl.tsx), [`apps/web/components/GuestCodecCameraButton.tsx`](../../apps/web/components/GuestCodecCameraButton.tsx), [`apps/web/components/GuestDeviceRequestDialog.tsx`](../../apps/web/components/GuestDeviceRequestDialog.tsx), [`apps/web/components/GuestEntrySettings.tsx`](../../apps/web/components/GuestEntrySettings.tsx), [`apps/web/components/GuestInviteLanding.tsx`](../../apps/web/components/GuestInviteLanding.tsx), [`apps/web/components/GuestMicrophoneButton.tsx`](../../apps/web/components/GuestMicrophoneButton.tsx), [`apps/web/components/GuestMicrophoneTest.tsx`](../../apps/web/components/GuestMicrophoneTest.tsx), [`apps/web/components/GuestNoiseSuppressionControl.tsx`](../../apps/web/components/GuestNoiseSuppressionControl.tsx), [`apps/web/components/GuestParticipantsTable.tsx`](../../apps/web/components/GuestParticipantsTable.tsx), [`apps/web/components/GuestPrivateTalkback.tsx`](../../apps/web/components/GuestPrivateTalkback.tsx), [`apps/web/components/GuestRoleOffer.tsx`](../../apps/web/components/GuestRoleOffer.tsx), [`apps/web/components/GuestServerPicker.tsx`](../../apps/web/components/GuestServerPicker.tsx), [`apps/web/components/GuestSettingsPanel.tsx`](../../apps/web/components/GuestSettingsPanel.tsx), [`apps/web/components/GuestVideoCard.tsx`](../../apps/web/components/GuestVideoCard.tsx), [`apps/web/components/GuestWaitingPanel.tsx`](../../apps/web/components/GuestWaitingPanel.tsx), [`apps/web/components/GuestWorkspace.tsx`](../../apps/web/components/GuestWorkspace.tsx), [`apps/web/components/HomeEntry.tsx`](../../apps/web/components/HomeEntry.tsx), [`apps/web/components/HomeLobby.tsx`](../../apps/web/components/HomeLobby.tsx), [`apps/web/components/HostedAccessGate.tsx`](../../apps/web/components/HostedAccessGate.tsx), [`apps/web/components/LanguageSwitcher.tsx`](../../apps/web/components/LanguageSwitcher.tsx), [`apps/web/components/MediaDevicePicker.tsx`](../../apps/web/components/MediaDevicePicker.tsx), [`apps/web/components/ModeratorInviteButton.tsx`](../../apps/web/components/ModeratorInviteButton.tsx), [`apps/web/components/ModeratorRoomTools.tsx`](../../apps/web/components/ModeratorRoomTools.tsx), [`apps/web/components/ModeratorStudioMonitoring.tsx`](../../apps/web/components/ModeratorStudioMonitoring.tsx), [`apps/web/components/MonitoringControls.tsx`](../../apps/web/components/MonitoringControls.tsx), [`apps/web/components/ParticipantRenameButton.tsx`](../../apps/web/components/ParticipantRenameButton.tsx), [`apps/web/components/ParticipantSourceCard.tsx`](../../apps/web/components/ParticipantSourceCard.tsx), [`apps/web/components/PdfPresentationViewer.tsx`](../../apps/web/components/PdfPresentationViewer.tsx), [`apps/web/components/PersonalCasterLink.tsx`](../../apps/web/components/PersonalCasterLink.tsx), [`apps/web/components/PlatformAccessGate.tsx`](../../apps/web/components/PlatformAccessGate.tsx), [`apps/web/components/PlatformParticipantActions.tsx`](../../apps/web/components/PlatformParticipantActions.tsx), [`apps/web/components/PresentationControls.tsx`](../../apps/web/components/PresentationControls.tsx), [`apps/web/components/RecoveryCode.tsx`](../../apps/web/components/RecoveryCode.tsx), [`apps/web/components/RemotePcControls.tsx`](../../apps/web/components/RemotePcControls.tsx), [`apps/web/components/RemotePcOwnerStatus.tsx`](../../apps/web/components/RemotePcOwnerStatus.tsx), [`apps/web/components/RemotePcTileControls.tsx`](../../apps/web/components/RemotePcTileControls.tsx), [`apps/web/components/RemoteStudioDevices.tsx`](../../apps/web/components/RemoteStudioDevices.tsx), [`apps/web/components/RemoteVideoPreview.tsx`](../../apps/web/components/RemoteVideoPreview.tsx), [`apps/web/components/RoomChat.tsx`](../../apps/web/components/RoomChat.tsx), [`apps/web/components/RoomConference.tsx`](../../apps/web/components/RoomConference.tsx), [`apps/web/components/RoomControlConsole.tsx`](../../apps/web/components/RoomControlConsole.tsx), [`apps/web/components/RoomCreationPasswordSettings.tsx`](../../apps/web/components/RoomCreationPasswordSettings.tsx), [`apps/web/components/RoomInviteButton.tsx`](../../apps/web/components/RoomInviteButton.tsx), [`apps/web/components/RoomManagementPanel.tsx`](../../apps/web/components/RoomManagementPanel.tsx), [`apps/web/components/RoomReactions.tsx`](../../apps/web/components/RoomReactions.tsx), [`apps/web/components/RotateVideoButton.tsx`](../../apps/web/components/RotateVideoButton.tsx), [`apps/web/components/ScreenShareButton.tsx`](../../apps/web/components/ScreenShareButton.tsx), [`apps/web/components/ScreenShareSettings.tsx`](../../apps/web/components/ScreenShareSettings.tsx), [`apps/web/components/ServerConnections.tsx`](../../apps/web/components/ServerConnections.tsx), [`apps/web/components/ServerEventJournal.tsx`](../../apps/web/components/ServerEventJournal.tsx), [`apps/web/components/ServerMonitor.tsx`](../../apps/web/components/ServerMonitor.tsx), [`apps/web/components/ServerRecoveryConnections.tsx`](../../apps/web/components/ServerRecoveryConnections.tsx), [`apps/web/components/ServerTransferPanel.tsx`](../../apps/web/components/ServerTransferPanel.tsx), [`apps/web/components/SourceRoster.tsx`](../../apps/web/components/SourceRoster.tsx), [`apps/web/components/StaffChatPanel.tsx`](../../apps/web/components/StaffChatPanel.tsx), [`apps/web/components/StaffParticipantsPanel.tsx`](../../apps/web/components/StaffParticipantsPanel.tsx), [`apps/web/components/StatisticsDashboard.tsx`](../../apps/web/components/StatisticsDashboard.tsx), [`apps/web/components/StudioCasterImageControls.tsx`](../../apps/web/components/StudioCasterImageControls.tsx), [`apps/web/components/StudioConsolePopover.tsx`](../../apps/web/components/StudioConsolePopover.tsx), [`apps/web/components/StudioGuestDeviceControls.tsx`](../../apps/web/components/StudioGuestDeviceControls.tsx), [`apps/web/components/StudioGuestMediaControls.tsx`](../../apps/web/components/StudioGuestMediaControls.tsx), [`apps/web/components/StudioGuestMediaPanel.tsx`](../../apps/web/components/StudioGuestMediaPanel.tsx), [`apps/web/components/StudioGuestRoleControls.tsx`](../../apps/web/components/StudioGuestRoleControls.tsx), [`apps/web/components/StudioOwnerGate.tsx`](../../apps/web/components/StudioOwnerGate.tsx), [`apps/web/components/StudioPageClient.tsx`](../../apps/web/components/StudioPageClient.tsx), [`apps/web/components/StudioReturnPublishingControls.tsx`](../../apps/web/components/StudioReturnPublishingControls.tsx), [`apps/web/components/StudioReturnStatsPanel.tsx`](../../apps/web/components/StudioReturnStatsPanel.tsx), [`apps/web/components/StudioRoomPicker.tsx`](../../apps/web/components/StudioRoomPicker.tsx), [`apps/web/components/StudioStreamInviteButton.tsx`](../../apps/web/components/StudioStreamInviteButton.tsx), [`apps/web/components/StudioVideoModeControls.tsx`](../../apps/web/components/StudioVideoModeControls.tsx), [`apps/web/components/StudioWorkspace.tsx`](../../apps/web/components/StudioWorkspace.tsx), [`apps/web/components/TalkbackControls.tsx`](../../apps/web/components/TalkbackControls.tsx), [`apps/web/components/ThemeToggle.tsx`](../../apps/web/components/ThemeToggle.tsx), [`apps/web/components/multiview/ImageTransformControls.tsx`](../../apps/web/components/multiview/ImageTransformControls.tsx), [`apps/web/components/multiview/MultiviewEditor.tsx`](../../apps/web/components/multiview/MultiviewEditor.tsx), [`apps/web/components/multiview/MultiviewMedia.tsx`](../../apps/web/components/multiview/MultiviewMedia.tsx), [`apps/web/tests/fixtures/remote-pc-committed-text.tsx`](../../apps/web/tests/fixtures/remote-pc-committed-text.tsx), [`apps/web/tests/fixtures/remote-pc-controller-layout.tsx`](../../apps/web/tests/fixtures/remote-pc-controller-layout.tsx), [`apps/web/tests/fixtures/room-reactions-ui.tsx`](../../apps/web/tests/fixtures/room-reactions-ui.tsx), [`apps/web/tests/fixtures/studio-compact.tsx`](../../apps/web/tests/fixtures/studio-compact.tsx), [`apps/web/tests/fixtures/ui-layout-corrections.tsx`](../../apps/web/tests/fixtures/ui-layout-corrections.tsx), [`apps/web/tests/guest-entry.cjs`](../../apps/web/tests/guest-entry.cjs), [`apps/web/tests/i18n.browser.cjs`](../../apps/web/tests/i18n.browser.cjs). +Используют: [`apps/web/app/diagnostics/page.tsx`](../../apps/web/app/diagnostics/page.tsx), [`apps/web/app/error.tsx`](../../apps/web/app/error.tsx), [`apps/web/app/join/page.tsx`](../../apps/web/app/join/page.tsx), [`apps/web/app/monitor/page.tsx`](../../apps/web/app/monitor/page.tsx), [`apps/web/app/not-found.tsx`](../../apps/web/app/not-found.tsx), [`apps/web/app/output/[identity]/page.tsx`](../../apps/web/app/output/%5Bidentity%5D/page.tsx), [`apps/web/app/output/slot/[slotId]/page.tsx`](../../apps/web/app/output/slot/%5BslotId%5D/page.tsx), [`apps/web/app/remote-pc/publisher/page.tsx`](../../apps/web/app/remote-pc/publisher/page.tsx), [`apps/web/app/room/page.tsx`](../../apps/web/app/room/page.tsx), [`apps/web/app/servers/page.tsx`](../../apps/web/app/servers/page.tsx), [`apps/web/app/setup/page.tsx`](../../apps/web/app/setup/page.tsx), [`apps/web/app/studios/page.tsx`](../../apps/web/app/studios/page.tsx), [`apps/web/app/super-admin/page.tsx`](../../apps/web/app/super-admin/page.tsx), [`apps/web/app/transfer/page.tsx`](../../apps/web/app/transfer/page.tsx), [`apps/web/app/watch/page.tsx`](../../apps/web/app/watch/page.tsx), [`apps/web/components/AccessGate.tsx`](../../apps/web/components/AccessGate.tsx), [`apps/web/components/AdminInviteButton.tsx`](../../apps/web/components/AdminInviteButton.tsx), [`apps/web/components/AppChrome.tsx`](../../apps/web/components/AppChrome.tsx), [`apps/web/components/AppUpdateNotice.tsx`](../../apps/web/components/AppUpdateNotice.tsx), [`apps/web/components/AudioMeter.tsx`](../../apps/web/components/AudioMeter.tsx), [`apps/web/components/AudioOutputSelect.tsx`](../../apps/web/components/AudioOutputSelect.tsx), [`apps/web/components/CasterSlotsPanel.tsx`](../../apps/web/components/CasterSlotsPanel.tsx), [`apps/web/components/ChatEmojis.tsx`](../../apps/web/components/ChatEmojis.tsx), [`apps/web/components/CoPresentationPanel.tsx`](../../apps/web/components/CoPresentationPanel.tsx), [`apps/web/components/ConferenceScene.tsx`](../../apps/web/components/ConferenceScene.tsx), [`apps/web/components/DesignPreview.tsx`](../../apps/web/components/DesignPreview.tsx), [`apps/web/components/EmergencyReservePanel.tsx`](../../apps/web/components/EmergencyReservePanel.tsx), [`apps/web/components/EntryShell.tsx`](../../apps/web/components/EntryShell.tsx), [`apps/web/components/GuestAccessGate.tsx`](../../apps/web/components/GuestAccessGate.tsx), [`apps/web/components/GuestAudioProfileSelector.tsx`](../../apps/web/components/GuestAudioProfileSelector.tsx), [`apps/web/components/GuestAvCheck.tsx`](../../apps/web/components/GuestAvCheck.tsx), [`apps/web/components/GuestCameraButton.tsx`](../../apps/web/components/GuestCameraButton.tsx), [`apps/web/components/GuestCameraCaptureControls.tsx`](../../apps/web/components/GuestCameraCaptureControls.tsx), [`apps/web/components/GuestCameraResolutionControl.tsx`](../../apps/web/components/GuestCameraResolutionControl.tsx), [`apps/web/components/GuestCodecCameraButton.tsx`](../../apps/web/components/GuestCodecCameraButton.tsx), [`apps/web/components/GuestDeviceRequestDialog.tsx`](../../apps/web/components/GuestDeviceRequestDialog.tsx), [`apps/web/components/GuestEntrySettings.tsx`](../../apps/web/components/GuestEntrySettings.tsx), [`apps/web/components/GuestInviteLanding.tsx`](../../apps/web/components/GuestInviteLanding.tsx), [`apps/web/components/GuestMicrophoneButton.tsx`](../../apps/web/components/GuestMicrophoneButton.tsx), [`apps/web/components/GuestMicrophoneTest.tsx`](../../apps/web/components/GuestMicrophoneTest.tsx), [`apps/web/components/GuestNoiseSuppressionControl.tsx`](../../apps/web/components/GuestNoiseSuppressionControl.tsx), [`apps/web/components/GuestParticipantsTable.tsx`](../../apps/web/components/GuestParticipantsTable.tsx), [`apps/web/components/GuestPrivateTalkback.tsx`](../../apps/web/components/GuestPrivateTalkback.tsx), [`apps/web/components/GuestRoleOffer.tsx`](../../apps/web/components/GuestRoleOffer.tsx), [`apps/web/components/GuestServerPicker.tsx`](../../apps/web/components/GuestServerPicker.tsx), [`apps/web/components/GuestSettingsPanel.tsx`](../../apps/web/components/GuestSettingsPanel.tsx), [`apps/web/components/GuestVideoCard.tsx`](../../apps/web/components/GuestVideoCard.tsx), [`apps/web/components/GuestWaitingPanel.tsx`](../../apps/web/components/GuestWaitingPanel.tsx), [`apps/web/components/GuestWorkspace.tsx`](../../apps/web/components/GuestWorkspace.tsx), [`apps/web/components/HomeEntry.tsx`](../../apps/web/components/HomeEntry.tsx), [`apps/web/components/HomeLobby.tsx`](../../apps/web/components/HomeLobby.tsx), [`apps/web/components/HostedAccessGate.tsx`](../../apps/web/components/HostedAccessGate.tsx), [`apps/web/components/LanguageSwitcher.tsx`](../../apps/web/components/LanguageSwitcher.tsx), [`apps/web/components/MediaDevicePicker.tsx`](../../apps/web/components/MediaDevicePicker.tsx), [`apps/web/components/ModeratorInviteButton.tsx`](../../apps/web/components/ModeratorInviteButton.tsx), [`apps/web/components/ModeratorRoomTools.tsx`](../../apps/web/components/ModeratorRoomTools.tsx), [`apps/web/components/ModeratorStudioMonitoring.tsx`](../../apps/web/components/ModeratorStudioMonitoring.tsx), [`apps/web/components/MonitoringControls.tsx`](../../apps/web/components/MonitoringControls.tsx), [`apps/web/components/ParticipantRenameButton.tsx`](../../apps/web/components/ParticipantRenameButton.tsx), [`apps/web/components/ParticipantSourceCard.tsx`](../../apps/web/components/ParticipantSourceCard.tsx), [`apps/web/components/PdfPresentationViewer.tsx`](../../apps/web/components/PdfPresentationViewer.tsx), [`apps/web/components/PersonalCasterLink.tsx`](../../apps/web/components/PersonalCasterLink.tsx), [`apps/web/components/PlatformAccessGate.tsx`](../../apps/web/components/PlatformAccessGate.tsx), [`apps/web/components/PlatformParticipantActions.tsx`](../../apps/web/components/PlatformParticipantActions.tsx), [`apps/web/components/PresentationControls.tsx`](../../apps/web/components/PresentationControls.tsx), [`apps/web/components/RecoveryCode.tsx`](../../apps/web/components/RecoveryCode.tsx), [`apps/web/components/RemotePcControls.tsx`](../../apps/web/components/RemotePcControls.tsx), [`apps/web/components/RemotePcOwnerStatus.tsx`](../../apps/web/components/RemotePcOwnerStatus.tsx), [`apps/web/components/RemotePcTileControls.tsx`](../../apps/web/components/RemotePcTileControls.tsx), [`apps/web/components/RemoteStudioDevices.tsx`](../../apps/web/components/RemoteStudioDevices.tsx), [`apps/web/components/RemoteVideoPreview.tsx`](../../apps/web/components/RemoteVideoPreview.tsx), [`apps/web/components/RoomChat.tsx`](../../apps/web/components/RoomChat.tsx), [`apps/web/components/RoomConference.tsx`](../../apps/web/components/RoomConference.tsx), [`apps/web/components/RoomControlConsole.tsx`](../../apps/web/components/RoomControlConsole.tsx), [`apps/web/components/RoomCreationPasswordSettings.tsx`](../../apps/web/components/RoomCreationPasswordSettings.tsx), [`apps/web/components/RoomInviteButton.tsx`](../../apps/web/components/RoomInviteButton.tsx), [`apps/web/components/RoomManagementPanel.tsx`](../../apps/web/components/RoomManagementPanel.tsx), [`apps/web/components/RoomReactions.tsx`](../../apps/web/components/RoomReactions.tsx), [`apps/web/components/RotateVideoButton.tsx`](../../apps/web/components/RotateVideoButton.tsx), [`apps/web/components/ScreenShareButton.tsx`](../../apps/web/components/ScreenShareButton.tsx), [`apps/web/components/ScreenShareSettings.tsx`](../../apps/web/components/ScreenShareSettings.tsx), [`apps/web/components/ServerConnections.tsx`](../../apps/web/components/ServerConnections.tsx), [`apps/web/components/ServerEventJournal.tsx`](../../apps/web/components/ServerEventJournal.tsx), [`apps/web/components/ServerMonitor.tsx`](../../apps/web/components/ServerMonitor.tsx), [`apps/web/components/ServerRecoveryConnections.tsx`](../../apps/web/components/ServerRecoveryConnections.tsx), [`apps/web/components/ServerTransferPanel.tsx`](../../apps/web/components/ServerTransferPanel.tsx), [`apps/web/components/SourceRoster.tsx`](../../apps/web/components/SourceRoster.tsx), [`apps/web/components/StaffChatPanel.tsx`](../../apps/web/components/StaffChatPanel.tsx), [`apps/web/components/StaffParticipantsPanel.tsx`](../../apps/web/components/StaffParticipantsPanel.tsx), [`apps/web/components/StatisticsDashboard.tsx`](../../apps/web/components/StatisticsDashboard.tsx), [`apps/web/components/StudioCasterImageControls.tsx`](../../apps/web/components/StudioCasterImageControls.tsx), [`apps/web/components/StudioConsolePopover.tsx`](../../apps/web/components/StudioConsolePopover.tsx), [`apps/web/components/StudioGuestDeviceControls.tsx`](../../apps/web/components/StudioGuestDeviceControls.tsx), [`apps/web/components/StudioGuestMediaControls.tsx`](../../apps/web/components/StudioGuestMediaControls.tsx), [`apps/web/components/StudioGuestMediaPanel.tsx`](../../apps/web/components/StudioGuestMediaPanel.tsx), [`apps/web/components/StudioGuestRoleControls.tsx`](../../apps/web/components/StudioGuestRoleControls.tsx), [`apps/web/components/StudioOwnerGate.tsx`](../../apps/web/components/StudioOwnerGate.tsx), [`apps/web/components/StudioPageClient.tsx`](../../apps/web/components/StudioPageClient.tsx), [`apps/web/components/StudioReturnPublishingControls.tsx`](../../apps/web/components/StudioReturnPublishingControls.tsx), [`apps/web/components/StudioReturnStatsPanel.tsx`](../../apps/web/components/StudioReturnStatsPanel.tsx), [`apps/web/components/StudioRoomPicker.tsx`](../../apps/web/components/StudioRoomPicker.tsx), [`apps/web/components/StudioStreamInviteButton.tsx`](../../apps/web/components/StudioStreamInviteButton.tsx), [`apps/web/components/StudioVideoModeControls.tsx`](../../apps/web/components/StudioVideoModeControls.tsx), [`apps/web/components/StudioWorkspace.tsx`](../../apps/web/components/StudioWorkspace.tsx), [`apps/web/components/TalkbackControls.tsx`](../../apps/web/components/TalkbackControls.tsx), [`apps/web/components/ThemeToggle.tsx`](../../apps/web/components/ThemeToggle.tsx), [`apps/web/components/multiview/ImageTransformControls.tsx`](../../apps/web/components/multiview/ImageTransformControls.tsx), [`apps/web/components/multiview/MultiviewEditor.tsx`](../../apps/web/components/multiview/MultiviewEditor.tsx), [`apps/web/components/multiview/MultiviewMedia.tsx`](../../apps/web/components/multiview/MultiviewMedia.tsx), [`apps/web/tests/fixtures/remote-pc-committed-text.tsx`](../../apps/web/tests/fixtures/remote-pc-committed-text.tsx), [`apps/web/tests/fixtures/remote-pc-controller-layout.tsx`](../../apps/web/tests/fixtures/remote-pc-controller-layout.tsx), [`apps/web/tests/fixtures/room-reactions-ui.tsx`](../../apps/web/tests/fixtures/room-reactions-ui.tsx), [`apps/web/tests/fixtures/studio-compact.tsx`](../../apps/web/tests/fixtures/studio-compact.tsx), [`apps/web/tests/fixtures/ui-layout-corrections.tsx`](../../apps/web/tests/fixtures/ui-layout-corrections.tsx), [`apps/web/tests/guest-audio-switch.browser.cjs`](../../apps/web/tests/guest-audio-switch.browser.cjs), [`apps/web/tests/guest-entry.cjs`](../../apps/web/tests/guest-entry.cjs), [`apps/web/tests/i18n.browser.cjs`](../../apps/web/tests/i18n.browser.cjs). ### apps/web/components/LanguageSwitcher.tsx @@ -6552,11 +6564,11 @@ UI gate только по отдельной Super Admin сессии; прав Экспортируемые символы: `GuestControlActivity`, `StudioGuestMediaControls`. -Читает/импортирует: [`apps/web/components/GuestCameraCaptureControls.tsx`](../../apps/web/components/GuestCameraCaptureControls.tsx), [`apps/web/components/I18nProvider.tsx`](../../apps/web/components/I18nProvider.tsx), [`apps/web/components/StudioGuestDeviceControls.tsx`](../../apps/web/components/StudioGuestDeviceControls.tsx), [`apps/web/lib/connection-route.ts`](../../apps/web/lib/connection-route.ts), [`apps/web/lib/guest-media-control.ts`](../../apps/web/lib/guest-media-control.ts), [`apps/web/lib/i18n/workspaces.ts`](../../apps/web/lib/i18n/workspaces.ts), [`apps/web/lib/video-quality.ts`](../../apps/web/lib/video-quality.ts). +Читает/импортирует: [`apps/web/components/GuestAudioProfileSelector.tsx`](../../apps/web/components/GuestAudioProfileSelector.tsx), [`apps/web/components/GuestCameraCaptureControls.tsx`](../../apps/web/components/GuestCameraCaptureControls.tsx), [`apps/web/components/I18nProvider.tsx`](../../apps/web/components/I18nProvider.tsx), [`apps/web/components/StudioGuestDeviceControls.tsx`](../../apps/web/components/StudioGuestDeviceControls.tsx), [`apps/web/lib/connection-route.ts`](../../apps/web/lib/connection-route.ts), [`apps/web/lib/guest-media-control.ts`](../../apps/web/lib/guest-media-control.ts), [`apps/web/lib/i18n/workspaces.ts`](../../apps/web/lib/i18n/workspaces.ts), [`apps/web/lib/video-quality.ts`](../../apps/web/lib/video-quality.ts). Библиотеки/Node: `react`. -Используют: [`apps/web/components/StudioGuestMediaPanel.tsx`](../../apps/web/components/StudioGuestMediaPanel.tsx), [`apps/web/tests/fixtures/studio-compact.tsx`](../../apps/web/tests/fixtures/studio-compact.tsx), [`apps/web/tests/guest-media-controls-ui.cjs`](../../apps/web/tests/guest-media-controls-ui.cjs). +Используют: [`apps/web/components/StudioGuestMediaPanel.tsx`](../../apps/web/components/StudioGuestMediaPanel.tsx), [`apps/web/tests/fixtures/studio-compact.tsx`](../../apps/web/tests/fixtures/studio-compact.tsx), [`apps/web/tests/guest-audio-switch.browser.cjs`](../../apps/web/tests/guest-audio-switch.browser.cjs), [`apps/web/tests/guest-media-controls-ui.cjs`](../../apps/web/tests/guest-media-controls-ui.cjs). ### apps/web/components/StudioGuestMediaPanel.tsx @@ -9942,7 +9954,7 @@ React-жизненный цикл поворота: metadata и resize → ге Библиотеки/Node: `node:fs/promises`, `node:url`. -## apps/web/tests · 473 файлов +## apps/web/tests · 475 файлов ### apps/web/tests/access-secrets.cjs @@ -11298,6 +11310,28 @@ React-жизненный цикл поворота: metadata и resize → ге Библиотеки/Node: `node:assert/strict`, `node:fs`, `node:module`, `typescript`. +### apps/web/tests/guest-audio-switch.browser.cjs + +[`apps/web/tests/guest-audio-switch.browser.cjs`](../../apps/web/tests/guest-audio-switch.browser.cjs) + +Проверка/тестовый инструмент. PASS: keyboard, default off, studio on/off/ACK/error, 12 responsive theme/locale cases; screenshots: ${out} + +Читает/импортирует: [`apps/web/components/GuestAudioProfileSelector.tsx`](../../apps/web/components/GuestAudioProfileSelector.tsx), [`apps/web/components/I18nProvider.tsx`](../../apps/web/components/I18nProvider.tsx), [`apps/web/components/StudioGuestMediaControls.tsx`](../../apps/web/components/StudioGuestMediaControls.tsx), [`apps/web/tests/load-typescript.cjs`](../../apps/web/tests/load-typescript.cjs). + +Библиотеки/Node: `node:assert/strict`, `node:fs`, `node:http`, `node:module`, `node:os`, `node:path`, `react`, `react-dom/client`. + +Переменные окружения (только имена): `CASTLINK_QA_CHROMIUM_BINARY`, `CASTLINK_QA_NODE_MODULES`, `CASTLINK_QA_OUT`, `NODE_ENV`. + +### apps/web/tests/guest-audio-switch.cjs + +[`apps/web/tests/guest-audio-switch.cjs`](../../apps/web/tests/guest-audio-switch.cjs) + +Проверка/тестовый инструмент. PASS: one accessible processing switch defaults off, restores saved choices and controls noise/echo together + +Читает/импортирует: [`apps/web/tests/load-typescript.cjs`](../../apps/web/tests/load-typescript.cjs). + +Библиотеки/Node: `node:assert/strict`. + ### apps/web/tests/guest-audio.cjs [`apps/web/tests/guest-audio.cjs`](../../apps/web/tests/guest-audio.cjs) @@ -11540,7 +11574,7 @@ React-жизненный цикл поворота: metadata и resize → ге Проверка/тестовый инструмент. PASS: confirmed guest controls, draft/apply/cancel camera settings, actual capture, pending/offline/old-client states and cleanup -Читает/импортирует: [`apps/web/components/GuestCameraCaptureControls.tsx`](../../apps/web/components/GuestCameraCaptureControls.tsx), [`apps/web/components/StudioGuestMediaControls.tsx`](../../apps/web/components/StudioGuestMediaControls.tsx), [`apps/web/components/StudioGuestMediaPanel.tsx`](../../apps/web/components/StudioGuestMediaPanel.tsx), [`apps/web/tests/i18n-fixture.cjs`](../../apps/web/tests/i18n-fixture.cjs). +Читает/импортирует: [`apps/web/components/GuestCameraCaptureControls.tsx`](../../apps/web/components/GuestCameraCaptureControls.tsx), [`apps/web/components/StudioGuestMediaControls.tsx`](../../apps/web/components/StudioGuestMediaControls.tsx), [`apps/web/components/StudioGuestMediaPanel.tsx`](../../apps/web/components/StudioGuestMediaPanel.tsx), [`apps/web/tests/i18n-fixture.cjs`](../../apps/web/tests/i18n-fixture.cjs), [`apps/web/tests/load-typescript.cjs`](../../apps/web/tests/load-typescript.cjs). Библиотеки/Node: `node:assert/strict`, `node:fs`, `node:module`, `typescript`. @@ -11576,9 +11610,9 @@ React-жизненный цикл поворота: metadata и resize → ге Проверка/тестовый инструмент. PASS: caster can select speech/raw, actual/pending/unconfirmed processing and busy/disabled states -Читает/импортирует: [`apps/web/components/GuestNoiseSuppressionControl.tsx`](../../apps/web/components/GuestNoiseSuppressionControl.tsx), [`apps/web/tests/i18n-fixture.cjs`](../../apps/web/tests/i18n-fixture.cjs). +Читает/импортирует: [`apps/web/tests/i18n-fixture.cjs`](../../apps/web/tests/i18n-fixture.cjs), [`apps/web/tests/load-typescript.cjs`](../../apps/web/tests/load-typescript.cjs). -Библиотеки/Node: `node:assert/strict`, `node:fs`, `typescript`. +Библиотеки/Node: `node:assert/strict`. ### apps/web/tests/guest-participants-table.cjs @@ -11834,7 +11868,7 @@ React-жизненный цикл поворота: metadata и resize → ге Библиотеки/Node: `node:fs`, `node:module`, `node:path`, `node:vm`, `typescript`. -Используют: [`apps/desktop/tests/remote-pc-wire-parity.test.cjs`](../../apps/desktop/tests/remote-pc-wire-parity.test.cjs), [`apps/web/tests/browser-runtime-diagnostics.cjs`](../../apps/web/tests/browser-runtime-diagnostics.cjs), [`apps/web/tests/caster-invite-delete-ui.cjs`](../../apps/web/tests/caster-invite-delete-ui.cjs), [`apps/web/tests/caster-invite-delete.cjs`](../../apps/web/tests/caster-invite-delete.cjs), [`apps/web/tests/caster-slot-assignment.cjs`](../../apps/web/tests/caster-slot-assignment.cjs), [`apps/web/tests/caster-slot-reconnect.cjs`](../../apps/web/tests/caster-slot-reconnect.cjs), [`apps/web/tests/caster-slot-store.cjs`](../../apps/web/tests/caster-slot-store.cjs), [`apps/web/tests/caster-slots-ui.cjs`](../../apps/web/tests/caster-slots-ui.cjs), [`apps/web/tests/caster-toolbar-row.browser.cjs`](../../apps/web/tests/caster-toolbar-row.browser.cjs), [`apps/web/tests/caster-workspace-layout.cjs`](../../apps/web/tests/caster-workspace-layout.cjs), [`apps/web/tests/client-errors-routing.cjs`](../../apps/web/tests/client-errors-routing.cjs), [`apps/web/tests/co-presentation-acceptance-races.cjs`](../../apps/web/tests/co-presentation-acceptance-races.cjs), [`apps/web/tests/co-presentation-acceptance.cjs`](../../apps/web/tests/co-presentation-acceptance.cjs), [`apps/web/tests/co-presentation-browser-gesture.cjs`](../../apps/web/tests/co-presentation-browser-gesture.cjs), [`apps/web/tests/co-presentation-browser-helper-client.cjs`](../../apps/web/tests/co-presentation-browser-helper-client.cjs), [`apps/web/tests/co-presentation-browser-helper.cjs`](../../apps/web/tests/co-presentation-browser-helper.cjs), [`apps/web/tests/co-presentation-connection-errors.cjs`](../../apps/web/tests/co-presentation-connection-errors.cjs), [`apps/web/tests/co-presentation-controls.cjs`](../../apps/web/tests/co-presentation-controls.cjs), [`apps/web/tests/co-presentation-floating-panel.cjs`](../../apps/web/tests/co-presentation-floating-panel.cjs), [`apps/web/tests/co-presentation-helper-client.cjs`](../../apps/web/tests/co-presentation-helper-client.cjs), [`apps/web/tests/co-presentation-helper-resume.cjs`](../../apps/web/tests/co-presentation-helper-resume.cjs), [`apps/web/tests/co-presentation-inline-menu.cjs`](../../apps/web/tests/co-presentation-inline-menu.cjs), [`apps/web/tests/co-presentation-menu.cjs`](../../apps/web/tests/co-presentation-menu.cjs), [`apps/web/tests/co-presentation-native-floating.cjs`](../../apps/web/tests/co-presentation-native-floating.cjs), [`apps/web/tests/co-presentation-navigation.cjs`](../../apps/web/tests/co-presentation-navigation.cjs), [`apps/web/tests/co-presentation-panel-lifecycle.cjs`](../../apps/web/tests/co-presentation-panel-lifecycle.cjs), [`apps/web/tests/co-presentation-recipient.cjs`](../../apps/web/tests/co-presentation-recipient.cjs), [`apps/web/tests/co-presentation-request.cjs`](../../apps/web/tests/co-presentation-request.cjs), [`apps/web/tests/co-presentation-screen-binding.cjs`](../../apps/web/tests/co-presentation-screen-binding.cjs), [`apps/web/tests/co-presentation-step-controls.cjs`](../../apps/web/tests/co-presentation-step-controls.cjs), [`apps/web/tests/co-presentation-toolbar-contract.cjs`](../../apps/web/tests/co-presentation-toolbar-contract.cjs), [`apps/web/tests/co-presentation-toolbar-control-failure.cjs`](../../apps/web/tests/co-presentation-toolbar-control-failure.cjs), [`apps/web/tests/co-presentation-window-frontend.cjs`](../../apps/web/tests/co-presentation-window-frontend.cjs), [`apps/web/tests/co-presentation-window-intent.cjs`](../../apps/web/tests/co-presentation-window-intent.cjs), [`apps/web/tests/compact-toolbar-render.cjs`](../../apps/web/tests/compact-toolbar-render.cjs), [`apps/web/tests/conference-audio-policy.cjs`](../../apps/web/tests/conference-audio-policy.cjs), [`apps/web/tests/conference-gallery-layout.cjs`](../../apps/web/tests/conference-gallery-layout.cjs), [`apps/web/tests/conference-portrait-tiles.cjs`](../../apps/web/tests/conference-portrait-tiles.cjs), [`apps/web/tests/conference-scene.cjs`](../../apps/web/tests/conference-scene.cjs), [`apps/web/tests/connection-history.cjs`](../../apps/web/tests/connection-history.cjs), [`apps/web/tests/desktop-stop-panel.cjs`](../../apps/web/tests/desktop-stop-panel.cjs), [`apps/web/tests/document-picture-in-picture.cjs`](../../apps/web/tests/document-picture-in-picture.cjs), [`apps/web/tests/entry-invite-hosted.cjs`](../../apps/web/tests/entry-invite-hosted.cjs), [`apps/web/tests/entry-links.cjs`](../../apps/web/tests/entry-links.cjs), [`apps/web/tests/entry-settings.browser.cjs`](../../apps/web/tests/entry-settings.browser.cjs), [`apps/web/tests/floating-panel-position.cjs`](../../apps/web/tests/floating-panel-position.cjs), [`apps/web/tests/fullscreen-chrome.cjs`](../../apps/web/tests/fullscreen-chrome.cjs), [`apps/web/tests/guest-admission-api.cjs`](../../apps/web/tests/guest-admission-api.cjs), [`apps/web/tests/guest-admission-client.cjs`](../../apps/web/tests/guest-admission-client.cjs), [`apps/web/tests/guest-admission-kick.cjs`](../../apps/web/tests/guest-admission-kick.cjs), [`apps/web/tests/guest-admission-token.cjs`](../../apps/web/tests/guest-admission-token.cjs), [`apps/web/tests/guest-admission.cjs`](../../apps/web/tests/guest-admission.cjs), [`apps/web/tests/guest-camera-blur-ui.cjs`](../../apps/web/tests/guest-camera-blur-ui.cjs), [`apps/web/tests/guest-camera-resolution-ui.cjs`](../../apps/web/tests/guest-camera-resolution-ui.cjs), [`apps/web/tests/guest-camera-segmentation.cjs`](../../apps/web/tests/guest-camera-segmentation.cjs), [`apps/web/tests/guest-resume-renewal.cjs`](../../apps/web/tests/guest-resume-renewal.cjs), [`apps/web/tests/guest-workspace-media.cjs`](../../apps/web/tests/guest-workspace-media.cjs), [`apps/web/tests/multiview-editor-grid.cjs`](../../apps/web/tests/multiview-editor-grid.cjs), [`apps/web/tests/output-source-quality.cjs`](../../apps/web/tests/output-source-quality.cjs), [`apps/web/tests/participant-role-lifecycle.cjs`](../../apps/web/tests/participant-role-lifecycle.cjs), [`apps/web/tests/participant-role-ui.cjs`](../../apps/web/tests/participant-role-ui.cjs), [`apps/web/tests/participant-video-resolution.cjs`](../../apps/web/tests/participant-video-resolution.cjs), [`apps/web/tests/persistent-json-retry.cjs`](../../apps/web/tests/persistent-json-retry.cjs), [`apps/web/tests/personal-monitoring-entry.cjs`](../../apps/web/tests/personal-monitoring-entry.cjs), [`apps/web/tests/presentation-file-client.cjs`](../../apps/web/tests/presentation-file-client.cjs), [`apps/web/tests/presentation-file.cjs`](../../apps/web/tests/presentation-file.cjs), [`apps/web/tests/presentation-owner-input.cjs`](../../apps/web/tests/presentation-owner-input.cjs), [`apps/web/tests/presentation-owner-surface.cjs`](../../apps/web/tests/presentation-owner-surface.cjs), [`apps/web/tests/presentation-show-controls.cjs`](../../apps/web/tests/presentation-show-controls.cjs), [`apps/web/tests/presentation-unified-stop.cjs`](../../apps/web/tests/presentation-unified-stop.cjs), [`apps/web/tests/presentation-upload-memory.cjs`](../../apps/web/tests/presentation-upload-memory.cjs), [`apps/web/tests/presentation-upload-middleware.cjs`](../../apps/web/tests/presentation-upload-middleware.cjs), [`apps/web/tests/presentation-upload-routing.cjs`](../../apps/web/tests/presentation-upload-routing.cjs), [`apps/web/tests/presentation-upload-stream.cjs`](../../apps/web/tests/presentation-upload-stream.cjs), [`apps/web/tests/presenter-browser-control.browser.cjs`](../../apps/web/tests/presenter-browser-control.browser.cjs), [`apps/web/tests/remote-pc-api.cjs`](../../apps/web/tests/remote-pc-api.cjs), [`apps/web/tests/remote-pc-client.cjs`](../../apps/web/tests/remote-pc-client.cjs), [`apps/web/tests/remote-pc-controller-ui.cjs`](../../apps/web/tests/remote-pc-controller-ui.cjs), [`apps/web/tests/remote-pc-controls-relay.cjs`](../../apps/web/tests/remote-pc-controls-relay.cjs), [`apps/web/tests/remote-pc-desktop.cjs`](../../apps/web/tests/remote-pc-desktop.cjs), [`apps/web/tests/remote-pc-floating-ui.cjs`](../../apps/web/tests/remote-pc-floating-ui.cjs), [`apps/web/tests/remote-pc-geometry-precision.cjs`](../../apps/web/tests/remote-pc-geometry-precision.cjs), [`apps/web/tests/remote-pc-protocol.cjs`](../../apps/web/tests/remote-pc-protocol.cjs), [`apps/web/tests/remote-pc-server.cjs`](../../apps/web/tests/remote-pc-server.cjs), [`apps/web/tests/remote-pc-state.cjs`](../../apps/web/tests/remote-pc-state.cjs), [`apps/web/tests/render-native-pptx.cjs`](../../apps/web/tests/render-native-pptx.cjs), [`apps/web/tests/room-chat-api.cjs`](../../apps/web/tests/room-chat-api.cjs), [`apps/web/tests/room-chat-auth.cjs`](../../apps/web/tests/room-chat-auth.cjs), [`apps/web/tests/room-chat-bearer.cjs`](../../apps/web/tests/room-chat-bearer.cjs), [`apps/web/tests/room-chat-clear-api.cjs`](../../apps/web/tests/room-chat-clear-api.cjs), [`apps/web/tests/room-chat-clear-store.cjs`](../../apps/web/tests/room-chat-clear-store.cjs), [`apps/web/tests/room-chat-client.cjs`](../../apps/web/tests/room-chat-client.cjs), [`apps/web/tests/room-chat-hook.cjs`](../../apps/web/tests/room-chat-hook.cjs), [`apps/web/tests/room-chat-store.cjs`](../../apps/web/tests/room-chat-store.cjs), [`apps/web/tests/room-creation-password-ui.cjs`](../../apps/web/tests/room-creation-password-ui.cjs), [`apps/web/tests/room-reactions.cjs`](../../apps/web/tests/room-reactions.cjs), [`apps/web/tests/rtc-telemetry-audio.cjs`](../../apps/web/tests/rtc-telemetry-audio.cjs), [`apps/web/tests/rtc-telemetry-reconnect.cjs`](../../apps/web/tests/rtc-telemetry-reconnect.cjs), [`apps/web/tests/rtc-telemetry-runtime.cjs`](../../apps/web/tests/rtc-telemetry-runtime.cjs), [`apps/web/tests/screen-share-browser-default.cjs`](../../apps/web/tests/screen-share-browser-default.cjs), [`apps/web/tests/screen-share-component.cjs`](../../apps/web/tests/screen-share-component.cjs), [`apps/web/tests/screen-share-identity-lifecycle.cjs`](../../apps/web/tests/screen-share-identity-lifecycle.cjs), [`apps/web/tests/screen-share-settings.cjs`](../../apps/web/tests/screen-share-settings.cjs), [`apps/web/tests/screen-share-stop-button.cjs`](../../apps/web/tests/screen-share-stop-button.cjs), [`apps/web/tests/screen-share-stop-panel.cjs`](../../apps/web/tests/screen-share-stop-panel.cjs), [`apps/web/tests/server-event-journal-ui.cjs`](../../apps/web/tests/server-event-journal-ui.cjs), [`apps/web/tests/server-event-journal.cjs`](../../apps/web/tests/server-event-journal.cjs), [`apps/web/tests/server-events-api.cjs`](../../apps/web/tests/server-events-api.cjs), [`apps/web/tests/server-monitor-aggregation.cjs`](../../apps/web/tests/server-monitor-aggregation.cjs), [`apps/web/tests/server-monitor-api.cjs`](../../apps/web/tests/server-monitor-api.cjs), [`apps/web/tests/server-monitor-collection.cjs`](../../apps/web/tests/server-monitor-collection.cjs), [`apps/web/tests/server-monitor-read-only.cjs`](../../apps/web/tests/server-monitor-read-only.cjs), [`apps/web/tests/server-monitor-ui.cjs`](../../apps/web/tests/server-monitor-ui.cjs), [`apps/web/tests/server-monitor.cjs`](../../apps/web/tests/server-monitor.cjs), [`apps/web/tests/server-observability-benchmark.cjs`](../../apps/web/tests/server-observability-benchmark.cjs), [`apps/web/tests/server-observability-events.cjs`](../../apps/web/tests/server-observability-events.cjs), [`apps/web/tests/server-observability-instrumentation.cjs`](../../apps/web/tests/server-observability-instrumentation.cjs), [`apps/web/tests/server-observability-node-errors.cjs`](../../apps/web/tests/server-observability-node-errors.cjs), [`apps/web/tests/server-observability-store.cjs`](../../apps/web/tests/server-observability-store.cjs), [`apps/web/tests/server-observability-worker.cjs`](../../apps/web/tests/server-observability-worker.cjs), [`apps/web/tests/server-slots-authority.cjs`](../../apps/web/tests/server-slots-authority.cjs), [`apps/web/tests/server-slots-postgres.cjs`](../../apps/web/tests/server-slots-postgres.cjs), [`apps/web/tests/session-http-bearer.cjs`](../../apps/web/tests/session-http-bearer.cjs), [`apps/web/tests/session-http-renewal.cjs`](../../apps/web/tests/session-http-renewal.cjs), [`apps/web/tests/slot-output-api.cjs`](../../apps/web/tests/slot-output-api.cjs), [`apps/web/tests/slot-output-controller.cjs`](../../apps/web/tests/slot-output-controller.cjs), [`apps/web/tests/slot-output-hosted-http.cjs`](../../apps/web/tests/slot-output-hosted-http.cjs), [`apps/web/tests/staff-chat-panel.cjs`](../../apps/web/tests/staff-chat-panel.cjs), [`apps/web/tests/staff-room-chat-hook.cjs`](../../apps/web/tests/staff-room-chat-hook.cjs), [`apps/web/tests/staff-room-chat.cjs`](../../apps/web/tests/staff-room-chat.cjs), [`apps/web/tests/studio-console-a.cjs`](../../apps/web/tests/studio-console-a.cjs), [`apps/web/tests/studio-guest-render.cjs`](../../apps/web/tests/studio-guest-render.cjs), [`apps/web/tests/studio-owner-access.cjs`](../../apps/web/tests/studio-owner-access.cjs), [`apps/web/tests/studio-return-meter.cjs`](../../apps/web/tests/studio-return-meter.cjs), [`apps/web/tests/studio-transfer-release.cjs`](../../apps/web/tests/studio-transfer-release.cjs), [`apps/web/tests/studio-transfer-request.cjs`](../../apps/web/tests/studio-transfer-request.cjs). +Используют: [`apps/desktop/tests/remote-pc-wire-parity.test.cjs`](../../apps/desktop/tests/remote-pc-wire-parity.test.cjs), [`apps/web/tests/browser-runtime-diagnostics.cjs`](../../apps/web/tests/browser-runtime-diagnostics.cjs), [`apps/web/tests/caster-invite-delete-ui.cjs`](../../apps/web/tests/caster-invite-delete-ui.cjs), [`apps/web/tests/caster-invite-delete.cjs`](../../apps/web/tests/caster-invite-delete.cjs), [`apps/web/tests/caster-slot-assignment.cjs`](../../apps/web/tests/caster-slot-assignment.cjs), [`apps/web/tests/caster-slot-reconnect.cjs`](../../apps/web/tests/caster-slot-reconnect.cjs), [`apps/web/tests/caster-slot-store.cjs`](../../apps/web/tests/caster-slot-store.cjs), [`apps/web/tests/caster-slots-ui.cjs`](../../apps/web/tests/caster-slots-ui.cjs), [`apps/web/tests/caster-toolbar-row.browser.cjs`](../../apps/web/tests/caster-toolbar-row.browser.cjs), [`apps/web/tests/caster-workspace-layout.cjs`](../../apps/web/tests/caster-workspace-layout.cjs), [`apps/web/tests/client-errors-routing.cjs`](../../apps/web/tests/client-errors-routing.cjs), [`apps/web/tests/co-presentation-acceptance-races.cjs`](../../apps/web/tests/co-presentation-acceptance-races.cjs), [`apps/web/tests/co-presentation-acceptance.cjs`](../../apps/web/tests/co-presentation-acceptance.cjs), [`apps/web/tests/co-presentation-browser-gesture.cjs`](../../apps/web/tests/co-presentation-browser-gesture.cjs), [`apps/web/tests/co-presentation-browser-helper-client.cjs`](../../apps/web/tests/co-presentation-browser-helper-client.cjs), [`apps/web/tests/co-presentation-browser-helper.cjs`](../../apps/web/tests/co-presentation-browser-helper.cjs), [`apps/web/tests/co-presentation-connection-errors.cjs`](../../apps/web/tests/co-presentation-connection-errors.cjs), [`apps/web/tests/co-presentation-controls.cjs`](../../apps/web/tests/co-presentation-controls.cjs), [`apps/web/tests/co-presentation-floating-panel.cjs`](../../apps/web/tests/co-presentation-floating-panel.cjs), [`apps/web/tests/co-presentation-helper-client.cjs`](../../apps/web/tests/co-presentation-helper-client.cjs), [`apps/web/tests/co-presentation-helper-resume.cjs`](../../apps/web/tests/co-presentation-helper-resume.cjs), [`apps/web/tests/co-presentation-inline-menu.cjs`](../../apps/web/tests/co-presentation-inline-menu.cjs), [`apps/web/tests/co-presentation-menu.cjs`](../../apps/web/tests/co-presentation-menu.cjs), [`apps/web/tests/co-presentation-native-floating.cjs`](../../apps/web/tests/co-presentation-native-floating.cjs), [`apps/web/tests/co-presentation-navigation.cjs`](../../apps/web/tests/co-presentation-navigation.cjs), [`apps/web/tests/co-presentation-panel-lifecycle.cjs`](../../apps/web/tests/co-presentation-panel-lifecycle.cjs), [`apps/web/tests/co-presentation-recipient.cjs`](../../apps/web/tests/co-presentation-recipient.cjs), [`apps/web/tests/co-presentation-request.cjs`](../../apps/web/tests/co-presentation-request.cjs), [`apps/web/tests/co-presentation-screen-binding.cjs`](../../apps/web/tests/co-presentation-screen-binding.cjs), [`apps/web/tests/co-presentation-step-controls.cjs`](../../apps/web/tests/co-presentation-step-controls.cjs), [`apps/web/tests/co-presentation-toolbar-contract.cjs`](../../apps/web/tests/co-presentation-toolbar-contract.cjs), [`apps/web/tests/co-presentation-toolbar-control-failure.cjs`](../../apps/web/tests/co-presentation-toolbar-control-failure.cjs), [`apps/web/tests/co-presentation-window-frontend.cjs`](../../apps/web/tests/co-presentation-window-frontend.cjs), [`apps/web/tests/co-presentation-window-intent.cjs`](../../apps/web/tests/co-presentation-window-intent.cjs), [`apps/web/tests/compact-toolbar-render.cjs`](../../apps/web/tests/compact-toolbar-render.cjs), [`apps/web/tests/conference-audio-policy.cjs`](../../apps/web/tests/conference-audio-policy.cjs), [`apps/web/tests/conference-gallery-layout.cjs`](../../apps/web/tests/conference-gallery-layout.cjs), [`apps/web/tests/conference-portrait-tiles.cjs`](../../apps/web/tests/conference-portrait-tiles.cjs), [`apps/web/tests/conference-scene.cjs`](../../apps/web/tests/conference-scene.cjs), [`apps/web/tests/connection-history.cjs`](../../apps/web/tests/connection-history.cjs), [`apps/web/tests/desktop-stop-panel.cjs`](../../apps/web/tests/desktop-stop-panel.cjs), [`apps/web/tests/document-picture-in-picture.cjs`](../../apps/web/tests/document-picture-in-picture.cjs), [`apps/web/tests/entry-invite-hosted.cjs`](../../apps/web/tests/entry-invite-hosted.cjs), [`apps/web/tests/entry-links.cjs`](../../apps/web/tests/entry-links.cjs), [`apps/web/tests/entry-settings.browser.cjs`](../../apps/web/tests/entry-settings.browser.cjs), [`apps/web/tests/floating-panel-position.cjs`](../../apps/web/tests/floating-panel-position.cjs), [`apps/web/tests/fullscreen-chrome.cjs`](../../apps/web/tests/fullscreen-chrome.cjs), [`apps/web/tests/guest-admission-api.cjs`](../../apps/web/tests/guest-admission-api.cjs), [`apps/web/tests/guest-admission-client.cjs`](../../apps/web/tests/guest-admission-client.cjs), [`apps/web/tests/guest-admission-kick.cjs`](../../apps/web/tests/guest-admission-kick.cjs), [`apps/web/tests/guest-admission-token.cjs`](../../apps/web/tests/guest-admission-token.cjs), [`apps/web/tests/guest-admission.cjs`](../../apps/web/tests/guest-admission.cjs), [`apps/web/tests/guest-audio-switch.browser.cjs`](../../apps/web/tests/guest-audio-switch.browser.cjs), [`apps/web/tests/guest-audio-switch.cjs`](../../apps/web/tests/guest-audio-switch.cjs), [`apps/web/tests/guest-camera-blur-ui.cjs`](../../apps/web/tests/guest-camera-blur-ui.cjs), [`apps/web/tests/guest-camera-resolution-ui.cjs`](../../apps/web/tests/guest-camera-resolution-ui.cjs), [`apps/web/tests/guest-camera-segmentation.cjs`](../../apps/web/tests/guest-camera-segmentation.cjs), [`apps/web/tests/guest-media-controls-ui.cjs`](../../apps/web/tests/guest-media-controls-ui.cjs), [`apps/web/tests/guest-noise-control.cjs`](../../apps/web/tests/guest-noise-control.cjs), [`apps/web/tests/guest-resume-renewal.cjs`](../../apps/web/tests/guest-resume-renewal.cjs), [`apps/web/tests/guest-workspace-media.cjs`](../../apps/web/tests/guest-workspace-media.cjs), [`apps/web/tests/multiview-editor-grid.cjs`](../../apps/web/tests/multiview-editor-grid.cjs), [`apps/web/tests/output-source-quality.cjs`](../../apps/web/tests/output-source-quality.cjs), [`apps/web/tests/participant-role-lifecycle.cjs`](../../apps/web/tests/participant-role-lifecycle.cjs), [`apps/web/tests/participant-role-ui.cjs`](../../apps/web/tests/participant-role-ui.cjs), [`apps/web/tests/participant-video-resolution.cjs`](../../apps/web/tests/participant-video-resolution.cjs), [`apps/web/tests/persistent-json-retry.cjs`](../../apps/web/tests/persistent-json-retry.cjs), [`apps/web/tests/personal-monitoring-entry.cjs`](../../apps/web/tests/personal-monitoring-entry.cjs), [`apps/web/tests/presentation-file-client.cjs`](../../apps/web/tests/presentation-file-client.cjs), [`apps/web/tests/presentation-file.cjs`](../../apps/web/tests/presentation-file.cjs), [`apps/web/tests/presentation-owner-input.cjs`](../../apps/web/tests/presentation-owner-input.cjs), [`apps/web/tests/presentation-owner-surface.cjs`](../../apps/web/tests/presentation-owner-surface.cjs), [`apps/web/tests/presentation-show-controls.cjs`](../../apps/web/tests/presentation-show-controls.cjs), [`apps/web/tests/presentation-unified-stop.cjs`](../../apps/web/tests/presentation-unified-stop.cjs), [`apps/web/tests/presentation-upload-memory.cjs`](../../apps/web/tests/presentation-upload-memory.cjs), [`apps/web/tests/presentation-upload-middleware.cjs`](../../apps/web/tests/presentation-upload-middleware.cjs), [`apps/web/tests/presentation-upload-routing.cjs`](../../apps/web/tests/presentation-upload-routing.cjs), [`apps/web/tests/presentation-upload-stream.cjs`](../../apps/web/tests/presentation-upload-stream.cjs), [`apps/web/tests/presenter-browser-control.browser.cjs`](../../apps/web/tests/presenter-browser-control.browser.cjs), [`apps/web/tests/private-talkback-controls.cjs`](../../apps/web/tests/private-talkback-controls.cjs), [`apps/web/tests/remote-pc-api.cjs`](../../apps/web/tests/remote-pc-api.cjs), [`apps/web/tests/remote-pc-client.cjs`](../../apps/web/tests/remote-pc-client.cjs), [`apps/web/tests/remote-pc-controller-ui.cjs`](../../apps/web/tests/remote-pc-controller-ui.cjs), [`apps/web/tests/remote-pc-controls-relay.cjs`](../../apps/web/tests/remote-pc-controls-relay.cjs), [`apps/web/tests/remote-pc-desktop.cjs`](../../apps/web/tests/remote-pc-desktop.cjs), [`apps/web/tests/remote-pc-floating-ui.cjs`](../../apps/web/tests/remote-pc-floating-ui.cjs), [`apps/web/tests/remote-pc-geometry-precision.cjs`](../../apps/web/tests/remote-pc-geometry-precision.cjs), [`apps/web/tests/remote-pc-protocol.cjs`](../../apps/web/tests/remote-pc-protocol.cjs), [`apps/web/tests/remote-pc-server.cjs`](../../apps/web/tests/remote-pc-server.cjs), [`apps/web/tests/remote-pc-state.cjs`](../../apps/web/tests/remote-pc-state.cjs), [`apps/web/tests/render-native-pptx.cjs`](../../apps/web/tests/render-native-pptx.cjs), [`apps/web/tests/room-chat-api.cjs`](../../apps/web/tests/room-chat-api.cjs), [`apps/web/tests/room-chat-auth.cjs`](../../apps/web/tests/room-chat-auth.cjs), [`apps/web/tests/room-chat-bearer.cjs`](../../apps/web/tests/room-chat-bearer.cjs), [`apps/web/tests/room-chat-clear-api.cjs`](../../apps/web/tests/room-chat-clear-api.cjs), [`apps/web/tests/room-chat-clear-store.cjs`](../../apps/web/tests/room-chat-clear-store.cjs), [`apps/web/tests/room-chat-client.cjs`](../../apps/web/tests/room-chat-client.cjs), [`apps/web/tests/room-chat-hook.cjs`](../../apps/web/tests/room-chat-hook.cjs), [`apps/web/tests/room-chat-store.cjs`](../../apps/web/tests/room-chat-store.cjs), [`apps/web/tests/room-creation-password-ui.cjs`](../../apps/web/tests/room-creation-password-ui.cjs), [`apps/web/tests/room-reactions.cjs`](../../apps/web/tests/room-reactions.cjs), [`apps/web/tests/rtc-telemetry-audio.cjs`](../../apps/web/tests/rtc-telemetry-audio.cjs), [`apps/web/tests/rtc-telemetry-reconnect.cjs`](../../apps/web/tests/rtc-telemetry-reconnect.cjs), [`apps/web/tests/rtc-telemetry-runtime.cjs`](../../apps/web/tests/rtc-telemetry-runtime.cjs), [`apps/web/tests/screen-share-browser-default.cjs`](../../apps/web/tests/screen-share-browser-default.cjs), [`apps/web/tests/screen-share-component.cjs`](../../apps/web/tests/screen-share-component.cjs), [`apps/web/tests/screen-share-identity-lifecycle.cjs`](../../apps/web/tests/screen-share-identity-lifecycle.cjs), [`apps/web/tests/screen-share-settings.cjs`](../../apps/web/tests/screen-share-settings.cjs), [`apps/web/tests/screen-share-stop-button.cjs`](../../apps/web/tests/screen-share-stop-button.cjs), [`apps/web/tests/screen-share-stop-panel.cjs`](../../apps/web/tests/screen-share-stop-panel.cjs), [`apps/web/tests/server-event-journal-ui.cjs`](../../apps/web/tests/server-event-journal-ui.cjs), [`apps/web/tests/server-event-journal.cjs`](../../apps/web/tests/server-event-journal.cjs), [`apps/web/tests/server-events-api.cjs`](../../apps/web/tests/server-events-api.cjs), [`apps/web/tests/server-monitor-aggregation.cjs`](../../apps/web/tests/server-monitor-aggregation.cjs), [`apps/web/tests/server-monitor-api.cjs`](../../apps/web/tests/server-monitor-api.cjs), [`apps/web/tests/server-monitor-collection.cjs`](../../apps/web/tests/server-monitor-collection.cjs), [`apps/web/tests/server-monitor-read-only.cjs`](../../apps/web/tests/server-monitor-read-only.cjs), [`apps/web/tests/server-monitor-ui.cjs`](../../apps/web/tests/server-monitor-ui.cjs), [`apps/web/tests/server-monitor.cjs`](../../apps/web/tests/server-monitor.cjs), [`apps/web/tests/server-observability-benchmark.cjs`](../../apps/web/tests/server-observability-benchmark.cjs), [`apps/web/tests/server-observability-events.cjs`](../../apps/web/tests/server-observability-events.cjs), [`apps/web/tests/server-observability-instrumentation.cjs`](../../apps/web/tests/server-observability-instrumentation.cjs), [`apps/web/tests/server-observability-node-errors.cjs`](../../apps/web/tests/server-observability-node-errors.cjs), [`apps/web/tests/server-observability-store.cjs`](../../apps/web/tests/server-observability-store.cjs), [`apps/web/tests/server-observability-worker.cjs`](../../apps/web/tests/server-observability-worker.cjs), [`apps/web/tests/server-slots-authority.cjs`](../../apps/web/tests/server-slots-authority.cjs), [`apps/web/tests/server-slots-postgres.cjs`](../../apps/web/tests/server-slots-postgres.cjs), [`apps/web/tests/session-http-bearer.cjs`](../../apps/web/tests/session-http-bearer.cjs), [`apps/web/tests/session-http-renewal.cjs`](../../apps/web/tests/session-http-renewal.cjs), [`apps/web/tests/slot-output-api.cjs`](../../apps/web/tests/slot-output-api.cjs), [`apps/web/tests/slot-output-controller.cjs`](../../apps/web/tests/slot-output-controller.cjs), [`apps/web/tests/slot-output-hosted-http.cjs`](../../apps/web/tests/slot-output-hosted-http.cjs), [`apps/web/tests/staff-chat-panel.cjs`](../../apps/web/tests/staff-chat-panel.cjs), [`apps/web/tests/staff-room-chat-hook.cjs`](../../apps/web/tests/staff-room-chat-hook.cjs), [`apps/web/tests/staff-room-chat.cjs`](../../apps/web/tests/staff-room-chat.cjs), [`apps/web/tests/studio-console-a.cjs`](../../apps/web/tests/studio-console-a.cjs), [`apps/web/tests/studio-guest-render.cjs`](../../apps/web/tests/studio-guest-render.cjs), [`apps/web/tests/studio-owner-access.cjs`](../../apps/web/tests/studio-owner-access.cjs), [`apps/web/tests/studio-return-meter.cjs`](../../apps/web/tests/studio-return-meter.cjs), [`apps/web/tests/studio-transfer-release.cjs`](../../apps/web/tests/studio-transfer-release.cjs), [`apps/web/tests/studio-transfer-request.cjs`](../../apps/web/tests/studio-transfer-request.cjs). ### apps/web/tests/manual-docs.cjs @@ -12624,7 +12658,7 @@ vm.runInNewContext(transpile(`, `node:assert/strict`, `node:crypto`, `node:fs`, Проверка/тестовый инструмент. PRIVATE_TALKBACK_CONTROLS_PASS -Читает/импортирует: [`apps/web/components/GuestPrivateTalkback.tsx`](../../apps/web/components/GuestPrivateTalkback.tsx), [`apps/web/components/TalkbackControls.tsx`](../../apps/web/components/TalkbackControls.tsx), [`apps/web/lib/private-talkback-reply.ts`](../../apps/web/lib/private-talkback-reply.ts), [`apps/web/lib/session-http-bearer.ts`](../../apps/web/lib/session-http-bearer.ts), [`apps/web/lib/talkback-policy.ts`](../../apps/web/lib/talkback-policy.ts), [`apps/web/tests/i18n-fixture.cjs`](../../apps/web/tests/i18n-fixture.cjs). +Читает/импортирует: [`apps/web/components/GuestPrivateTalkback.tsx`](../../apps/web/components/GuestPrivateTalkback.tsx), [`apps/web/components/TalkbackControls.tsx`](../../apps/web/components/TalkbackControls.tsx), [`apps/web/lib/private-talkback-reply.ts`](../../apps/web/lib/private-talkback-reply.ts), [`apps/web/lib/session-http-bearer.ts`](../../apps/web/lib/session-http-bearer.ts), [`apps/web/lib/talkback-policy.ts`](../../apps/web/lib/talkback-policy.ts), [`apps/web/tests/i18n-fixture.cjs`](../../apps/web/tests/i18n-fixture.cjs), [`apps/web/tests/load-typescript.cjs`](../../apps/web/tests/load-typescript.cjs). Библиотеки/Node: `node:assert/strict`, `node:events`, `node:fs`, `node:module`, `typescript`. diff --git a/package.json b/package.json index 284edda2..3bc06bee 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "studiolink", "private": true, "license": "MIT", - "version": "1.0.2", - "buildNumber": "2", + "version": "1.0.3", + "buildNumber": "3", "packageManager": "pnpm@9.15.0", "engines": { "node": ">=22.13.0" }, "scripts": { From 974ab62d3b84ea15ce1ca0f8ebd66ec8fc75cbaa Mon Sep 17 00:00:00 2001 From: resokol Date: Fri, 13 Nov 2026 10:19:35 +0300 Subject: [PATCH 2/3] fix: preserve deployed guest connection statistics --- apps/web/components/GuestMiniStats.tsx | 39 +++++ apps/web/components/GuestVideoCard.module.css | 33 +++++ apps/web/components/GuestVideoCard.tsx | 4 +- apps/web/components/StudioWorkspace.tsx | 38 ++++- apps/web/lib/guest-receive-bitrate.ts | 26 ++++ apps/web/lib/i18n/workspaces.ts | 8 ++ .../fixtures/guest-mini-stats-refresh.tsx | 133 ++++++++++++++++++ .../guest-mini-stats-refresh.browser.cjs | 130 +++++++++++++++++ apps/web/tests/guest-receive-bitrate.cjs | 27 ++++ apps/web/tests/run-ci.cjs | 1 + apps/web/tests/studio-guest-render.cjs | 23 ++- 11 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 apps/web/components/GuestMiniStats.tsx create mode 100644 apps/web/lib/guest-receive-bitrate.ts create mode 100644 apps/web/tests/fixtures/guest-mini-stats-refresh.tsx create mode 100644 apps/web/tests/guest-mini-stats-refresh.browser.cjs create mode 100644 apps/web/tests/guest-receive-bitrate.cjs diff --git a/apps/web/components/GuestMiniStats.tsx b/apps/web/components/GuestMiniStats.tsx new file mode 100644 index 00000000..a60b979d --- /dev/null +++ b/apps/web/components/GuestMiniStats.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useI18n } from "./I18nProvider"; +import styles from "./GuestVideoCard.module.css"; + +export type GuestMiniStatsData = { + receiveVideoKbps?: number | null; + receiveAudioKbps?: number | null; + networkMeasured?: boolean; + statsSampledAt?: number; + statsSampledAtRef?: { current: number }; + stability: "Хорошая" | "Средняя" | "Плохая"; + packetLoss: number; + jitterMs: number; + rttMs: number; + camera: boolean; + microphone: boolean; +}; + +export function GuestMiniStats({ guest }: { guest: GuestMiniStatsData }) { + const { t, locale } = useI18n(); + const [now, setNow] = useState(Date.now); + useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 2000); return () => clearInterval(timer); }, []); + const sampledAt = guest.statsSampledAtRef?.current ?? guest.statsSampledAt; + const fresh = sampledAt != null && sampledAt > 0 && now - sampledAt < 8000; + const measured = fresh && guest.networkMeasured; + const quality = !measured ? "unknown" : guest.stability === "Хорошая" ? "good" : guest.stability === "Средняя" ? "mid" : "bad"; + const bitrate = (value: number | null | undefined, enabled: boolean) => fresh && enabled && value != null && Number.isFinite(value) && value >= 0 + ? `${value.toLocaleString(locale)} ${t("кбит/с")}` : "—"; + const detail = measured ? t("Потери: {loss} пак. за всё время трека; джиттер: {jitter} мс; RTT: {rtt}", { + loss: guest.packetLoss, jitter: guest.jitterMs, rtt: guest.rttMs > 0 ? `${guest.rttMs} ${t("мс")}` : "—", + }) : t("Нет свежего замера соединения"); + return
+
{t("Приём видео")}
{bitrate(guest.receiveVideoKbps, guest.camera)}
+
{t("Приём аудио")}
{bitrate(guest.receiveAudioKbps, guest.microphone)}
+
{t("Сеть")}