feat(dictation): realtime streaming STT (live dictation) #118
Closed
vvzvlad
wants to merge 4 commits from
feature/streaming-dictation into develop
pull from: feature/streaming-dictation
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:test/244-part-b
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:feat/221-image-captions
vvzvlad:feat/git-sync
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/244-dataloss-bugs
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:develop
vvzvlad:feature/offline-sync
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
bug
documentation
duplicate
enhancement
epic
feature
good first issue
help wanted
idea
invalid
needs-human
question
refactor
review/approved
review/changes-requested
review/needs
security
status/blocked
status/done
status/in-progress
status/ready
test
wontfix
Something isn't working
Improvements or additions to documentation
This issue or pull request already exists
New feature or request
Large multi-phase effort spanning many changes
New functionality request
Good for newcomers
Extra attention is needed
Idea / proposal for discussion
This doesn't seem right
эскалация: нужно решение человека
Further information is requested
Code cleanup / refactoring
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Security / hardening issue
ждёт зависимость blocked_by
закрыто и проверено
в активной работе (мягкая заявка)
специфицировано, не заблокировано, ждёт исполнителя
Test coverage / test infrastructure
This will not be worked on
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#118
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feature/streaming-dictation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Realtime streaming dictation (live STT)
Implements
docs/streaming-dictation-plan.md: an optional realtime speech-to-text path layered on top of the existing batch dictation, so transcribed text appears as the user speaks. Batch dictation remains the default and the fallback.Architecture (plan A2 + B2)
/ai-realtime) ↔ OpenAI Realtime (rawws). The provider API key never leaves the server; the upstream URL is SSRF-checked before connecting; the gateway enforces thedictation+dictationRealtimegate, cookie-JWT auth, and per-user / per-workspace concurrency caps.⚠️ API contract note
The plan's §3 describes the OpenAI Realtime beta API, which was removed on 2026-05-12. This PR is implemented against the GA (2026) contract instead, verified against the live docs: no
OpenAI-Betaheader;session.updatewithsession.type:"transcription"; nestedaudio.input.format:{type:"audio/pcm",rate:24000};turn_detectioninsideaudio.input. Thedelta/completedevents are unchanged.Server
core/ai-chat/realtime/ai-realtime.service.ts— upstreamwsproxy: config resolve, SSRF check, pureparseUpstreamEvent(per-item_iddelta accumulation, trimmed final), idle/max-duration timeouts, idempotent teardown,testConnection(reusesopenSession). Unit-tested (*.spec.ts, 8 cases).core/ai-chat/realtime/ai-realtime.gateway.ts—@WebSocketGateway('/ai-realtime'), cookie-JWT auth, gate before opening upstream, concurrency caps; normalized eventsready/interim/final/error/closed.POST /ai-chat/realtime/testconnectivity probe.settings.ai.dictationRealtime(aiDictationRealtimeDTO) + providersttRealtimeModel/sttRealtimeBaseUrl(realtime key reusessttApiKey— no new secret).Client
dictation/audio/pcm16-worklet.ts— AudioWorklet resampling to 24 kHz mono PCM16 (Int16 LE), ~150 ms batches.dictation/services/realtime-dictation-client.ts+dictation/hooks/use-realtime-dictation.ts(status/start/stop/cancel+onInterim/onFinal).dictation/components/realtime-mic-button.tsx+editor/extensions/dictation-interim/ghost decoration; editor (dictation-group) and chat (chat-input) integration; AI settings UI (toggle, realtime model/endpoint, test button) + i18n.Verification
tsc --noEmitclean (server + client); server ESLint clean on changed files; realtime jest spec 8/8.vite build/pnpm lintshould run in CI (the isolated worktree lacked client dev tooling). Concurrency caps are per-process.🤖 Generated with Claude Code
Отчёт по тест-стратегии — gitmost PR #118 «feat(dictation): realtime streaming STT» — 2026-06-21
Таксономия слоёв (зафиксирована для бюджета пирамиды):
тесты сервиса с инъецированным фейковым
WebSocket(сетевого I/O нет).EditorView, gateway со связкой несколькихреальных коллабораторов + module-singleton счётчики + жизненный цикл сокета, транзакция БД.
1. Исполнительное резюме
module-testability-analystна модуль.Серверная новизна покрыта частично только в
parseUpstreamEvent; весь клиентский код — 0 %.браузерный AudioWorklet/getUserMedia, DI-обвязка, типы, тонкие react-query-обёртки).
WebSocketв сервис; экспорт/выделение чистых счётчикови
canConnectв шлюзе; вынос DSP-математики из AudioWorklet; добавление@vitest/coverage-v8в клиент(иначе клиентское покрытие в принципе не измеряется).
Сверка «уже покрыто» (прогон инструментом)
ai-realtime.service.tsparseUpstreamEvent(8 тестов ✅);openSession/testConnection/deriveRealtimeUrl— 0 %ai-realtime.gateway.tsai-settings.service.tsupdate/resolveдля realtime — 0 % funcsworkspace.service.tsexpect(service).toBeDefined()update-workspace.dto.tsaiDictationRealtimeповеденчески НЕ провереноdictation/**,dictation-interim/**@vitest/coverage-v8отсутствует2. Рекомендации по модулям
Модуль A — server-realtime-service (
core/ai-chat/realtime/ai-realtime.service.ts)deriveRealtimeUrl(стат., :447) иparseUpstreamEvent(:75) — уже чистые;rawDataToString(:478, private static) — опц. экспорт.parseUpstreamEvent— добор веток:delta/completedбезitem_id→ ignore;errorбезmessage→fallback на
describeProviderError;completedбез transcript и без acc →text:''; не-объектный JSON(
"42","null") → ignore. Ловит: падение/неверную ветку на битых upstream-кадрах. Рефактор не нужен.deriveRealtimeUrl— default при пустом base;https→wss/http→ws; нет дублирования/v1; снятиесуффикса
/realtime; непарсимый base → тихий fallback наapi.openai.com(фиксируем как умышленное —см. §4 риск). Ловит: дубль
/v1/v1, потерю порта, понижение схемы. Рефактор не нужен.openSessionhandshake [R-SVC1]: одинsession.updateсtype:'transcription',audio.input.formatPCM 24k,
server_vad; заголовокAuthorization: Bearer, нетOpenAI-Beta;languageтолько если задан;onReadyодин раз. Ловит: поломку контракта upstream-рукопожатия.openSessionconfig/SSRF-перила [R-SVC1]: нет драйвера →AiSttNotConfiguredExceptionи сокет НЕ создан;isUrlAllowed=false → throw и сокет НЕ создан; в guard уходит http-эквивалент ws-URL. Ловит: обход SSRF(сокет до проверки), ложный 503 при fallback
sttModel.openSessionмаршрутизация сообщений [R-SVC1]: interim→onInterim; completed→onFinal; error→teardown;ignore→без колбэков; сообщение после close — no-op. Ловит: use-after-teardown, путаницу interim/final.
appendAudio[R-SVC1]: сокет не OPEN → тихий no-op (PCM не уходит); OPEN → корректный base64;sendthrow→
onError+teardown; успешный append пере-взводит idle-таймер. Ловит: потерю/утечку аудио, утечку сессии.stop/closeидемпотентность [R-SVC1]: commit→teardown; ошибка commit не мешает teardown;двойной close →
onClosedровно один раз. Ловит: двойнойonClosed, утечку сокетов/листенеров.onError; close 1000→ без error; после teardown поздние тайм��ры не срабатывают. Ловит: утечку таймеров и невозврат слота.
testConnectionsettle-once [R-SVC1]: ready→{ok:true}; error→{ok:false}; таймаут 8s; not-configured;«поздний» handle всё равно закрыт. Ловит: двойной settle и утечку «late-handle».
ws).openSession; недетерминирован).isUrlAllowed/IP-диапазоны (покрытоssrf-guard.spec.tsна нижнем слое — только фактвызова guard);
AiSettingsService.resolve,describeProviderError(чужие модули); типы/интерфейсы.Модуль B — server-realtime-gateway (
realtime/ai-realtime.gateway.ts+ controller/module)incr/decr(:45/:51, module-private),canConnect(...)(логика :101–142),toBuffer(:230).decr/incr[R-GW1]: delete-at-zero;decrпо отсутствующему ключу НЕ уходит в минус и не создаётфантомный слот. Ловит: утечку/недосчёт слота concurrency. Высший ROI.
canConnect[R-GW2]: оба флага → allow;dictationxordictationRealtime→ deny; нетsettings.ai→deny; user-cap(1)/workspace-cap(5) с приоритетом сообщений;
>=без off-by-one. Ловит: обход гейта, off-by-one.toBuffer: Buffer/Uint8Array/ArrayBuffer → Buffer; строка/null → null. Ловит: краш/пересылку не-бинарных данных.handleStartlifecycle (прямойnewшлюза, мок сервиса/сокета): релейonReady→ready,onInterim→interim{itemId,text},onFinal,onClosedчистит handle; double-start guard;AiSttNotConfiguredvs
describeProviderError(без утечки ключа/стека). Ловит: регрессии формы событий, двойной старт, утечку секрета.handleAudio/handleStopguards: нет handle → нетappendAudio; Buffer → один append; не-бинарь → нет append.handleConnectionauth: невалидный/просроченный/отсутствующий cookie-JWT →error:Unauthorized+disconnect,счётчик НЕ инкрементнут. Ловит: анонимный сокет, утечку деталей, занятый слот на отклонённой авторизации.
handleConnectiongate+cap: гейт выкл → disconnect, счётчики чисты; cap превышен → отказ; оба cap провереныдо инкремента любого. Ловит: «грязный» счётчик при отказе, асимметричный инкремент.
handleDisconnectno-leak: декремент обоих map; no-op если соединение отклонено до инкремента; идемпотентностьдвойного disconnect (не в минус). Ловит: классическую утечку слота / уход счётчика в отрицательное. Высший ROI.
POST ai-chat/realtime/test→ admin-gate (CASL Manage Settings; не-админ → Forbidden,testConnectionне вызван) + неизменная форма
{ok:true}|{ok:false,error}. Ловит: обход admin-гейта, дрейф формы ответа.ai-chat.module.tsпровайдеры,@WebSocketGateway({...})опции (DI/framework wiring);socket.io/cookie/wsсами по себе;logger.error.Модуль C — server-ai-settings & workspace (
integrations/ai/*,core/workspace/*)deriveRealtimeUrl(дедуплицировано с модулем A — здесь НЕ дублируем);опц. вынос
applyAiSettingPatches(dto, before)изWorkspaceService.update.AiSettingsService.updatemerge/partial: DTO только с realtime-полями → в patch ровно эти ключи; DTO сchatModel→ realtime-поля НЕ затёрты; пустой patch → repo не вызван. Ловит: выпадение поля из allowlist(тихо не сохранится), затирание чужих полей. Рефактор не нужен.
AiSettingsService.resolvefallback ключа:sttApiKeyEncесть → дешифр; нет → fallback на chatapiKey.Ловит: регрессию «realtime-ключ переиспользует sttApiKey» (поломка диктовки у workspace с одним chat-ключом).
UpdateWorkspaceDto.aiDictationRealtime:true/falseок; не-boolean → ошибка; пропуск ок. Ловит: опечаткудекоратора (
@IsStringвместо@IsBoolean). ⚠️ Это закрывает обманчивый «100 %» line-coverage — сейчасповедение поля не проверено. ROI средний (регресс-пин).
WorkspaceService.updateветкаaiDictationRealtime[R-WS1]: верный ключ настройки'dictationRealtime'; audit-diff; поле удалено из dto до generic-update (иначе запись несуществующей колонки).UpdateAiSettingsDto.sttRealtimeBaseUrlнамеренно только@IsString()(нет@IsUrl()), а блокировка приватных/loopback адресов — в connect-timeisUrlAllowed.Тест прогоняет
http://169.254.169.254/v1→ http-эквивалент →isUrlAllowed⇒ok:false. Ловит: самыйтяжёлый класс — «закалили» DTO с иной allow/deny-семантикой ИЛИ убрали connect-time проверку ⇒ живой SSRF
(metadata-endpoint/внутренние сервисы). Высший ROI.
WorkspaceRepo.updateAiProviderSettings(покрыт unit-тестомupdateсверху);тривиальные
@IsStringполяsttRealtimeModel/sttRealtimeBaseUrl; field-agnostic admin-гейт (уже покрытworkspace-update-gate.spec.ts); типыai.types.ts; ORM-хелперы Kysely.Модуль D — client-realtime-dictation-core (
features/dictation/**)floatToPcm16LE(:104–120) [R-DSP1]; resampler-ядро (:54–89) [R-DSP2]; frame-аккумулятор(:16/:93–99) [R-DSP3]; reducer interim/final/cancel/stop (:320–357) [R-HOOK4];
baseLanguageSubtag(:374) [R-HOOK5];mapGetUserMediaError(:207–219, дублируетuse-dictation.ts) [R-HOOK6].floatToPcm16LE[R-DSP1]: +1→32767, −1→−32768, 0→0; clamp +2/−2 без переполнения; LE-порядок байт;property-тест «выход всегда в [−32768,32767]»; NaN/Inf. Ловит: переполнение/wrap, endianness, off-by-one. Высший ROI.
cross-quantum (разбиение на два
process()== целому); инвариант «нет OOB-чтения». Ловит: щелчки на стыках, OOB, дрейф.Ловит: неверную границу ~150 мс, потерю/дубль хвоста.
игнорирует последующее; аудио буферизуется до
readyи сливается по порядку;closedпослеstop— no-op.Ловит: потерю/дубль final, утечку interim после cancel, нарушение порядка.
baseLanguageSubtag[R-HOOK5]:en-US→en,en→en,''→undefined. Ловит: отправку region-locale, который STT отвергает.mapGetUserMediaError[R-HOOK6]: NotAllowed/NotFound/NotReadable/fallback. Ловит: неверный UX-текст; чинит дубль с batch-хуком.socket.io-client):RealtimeDictationClient(unit-класс): декодированиеready/interim/final/error/connect_errorс?? '';error-once guard;
disconnectснимает листенеры и сбрасывает флаг;?.-гварды при отсутствии сокета.RealtimeMicButton(component, мок хука): statusrecording→stop, иначе→start; переход recording→idle вызываетonInterim("")ровно раз. Ловит: «зависший» partial-текст в редакторе после остановки.закрыта, индикатор off. Покрывает: реальный AudioWorklet+getUserMedia+живой upstream — не воспроизводимо мок-ами.
AudioWorkletProcessor/process()/registerProcessor; реальныеgetUserMedia/MediaStream/AudioContext/AudioWorkletNode;socket.io-client;getAudioContextCtorwebkit-проба;audio-worklet.d.ts; пред-существующая batch-диктовка.Модуль E — client-editor-interim-decoration (
editor/extensions/dictation-interim/**)applyInterimMeta(meta, prev)(:33–44) [R-EXT1];clampRange(from,to,size)в
dictation-group.tsx(:34–35) [R-EXT3].setDictationInterim/clearDictationInterimтранзакция имеетdocChanged===false,steps.length===0,docравен прежнему. Ловит: «полезную» вставку interim в документ —тот самый класс дефектов, ради предотвращения которого PR и существует (загрязнение Yjs/history).
@tiptap/extension-history— серия interim-обновлений не добавляет undo-шагов; undoоткатывает напечатанный текст, не interim. Ловит: interim как undoable-шаг.
decorations: пустой текст →null(нет виджета); непустой → один widgetcontenteditable=falseу каретки;декорация маппится при правках (трекает
selection.head, а не устаревшую позицию).applyInterimMeta[R-EXT1] (merge/passthrough);DictationGroup—clampRangeoff-by-one [R-EXT3] + гварды
editor.isDestroyed(set/clear только на живом редакторе; realtime vs batch gating).extensions.ts; boilerplateDictationInterim.create; внутренностиDecoration.widget/DecorationSet(ProseMirror); инлайн-стили виджета (кромеcontenteditable=false);dictationInterimKey.Модуль F — client-ai-settings-ui & chat (
workspace/.../ai-provider-settings.tsx,ai-chat/chat-input.tsx)resolveCardStatus/isEndpointConfigured/resolveKeyField— уже экспортированы;resolveUrl(:89) [R-UI1 экспорт]; merge interim/final вchat-input.tsx(:84–93) [R-UI2 вынос].resolveKeyField: buffer→{set,value}; cleared+пусто→{set:'' }; нетронуто→{set:false}; buffer+cleared→buffer.Ловит: утечку/потерю write-only секрета (
sttApiKey). Высший ROI (security). Рефактор не нужен.isEndpointConfigured: model+base→true; наследование chat-base; whitespace-base как пустой. Ловит: ошибки trim/предиката.resolveCardStatus: 4 исхода, особенно «enabled но не configured → warning». Ловит: скрытие реального мисконфига.resolveUrl[R-UI1]: trailing-slash, fallback-цепочка. Ловит: двойной/потерянный слеш в hint.appendFinalToDraft(draft, final)[R-UI2]: пусто+final→final; непусто→"draft final"(один разделитель);накопление слева-направо. Ловит: лидирующий/двойной пробел/потерю текста. Высший ROI (ядро chat-диктовки).
onInterimставит dimmed-tail;onFinal/send/cancelчистят interim.Ловит: «зависший» ghost-хвост или двойной коммит.
testRealtimeConnection(мокapi): POST на верный маршрут/ai-chat/realtime/test(не префикс/workspace/ai-settings), распаковка{ok}-конверта, без тела запроса. Ловит: регрессию неверного URL (легкоскопировать соседний маршрут) и дрейф формы.
workspace.types.ts/типы сервиса; тонкие react-query-обёртки (useTest...Mutation,useAiSettingsQuery); неизменённые passthrough-api.post;handleToggle*(оптимистичная обвязка + Mantine/jotai);StatusDot/полный render компонентов (tautological snapshot);translation.json.3. Сквозные аспекты
форма
{ok}realtime-test (модуль B/F, серверная сторона владеет формой — клиентский контракт дедуплицирован сюда).floatToPcm16LE(выход всегда в int16-диапазоне),длина ресемпла, отсутствие OOB при случайных размерах квантов/частотах.
покрывается integration-тестом disconnect-no-leak; отдельный нагрузочный не требуется.
RealtimeSettings/ResolvedAiConfig(модули A/C); фейковыйWebSocketLike(EventEmitter с
readyState/send/close/on) для модуля A; фейковыйSocket(data/emit/disconnectкак jest-моки)для модуля B; стабы
getUserMedia/AudioContext/AudioWorkletNodeдля модуля D.4. Обнаруженные антипаттерны
AudioWorkletProcessor(
pcm16-worklet.ts); state-machine диктовки размазан по ref/effect (use-realtime-dictation.ts); merge-логикачата в JSX-замыканиях (
chat-input.tsx). → разблокируется выносом (см. §5).ai-provider-settings.tsx— 1109 строк;WorkspaceService— 17 зависимостей в конструкторе(
workspace.service.ts:57).sessionsPerUser/sessionsPerWorkspace(Maps,ai-realtime.gateway.ts:45+)без reset-хука → риск порядко-зависимых тестов между файлами. → требует [R-GW1].
workspace.service.spec.ts:33(expect(service).toBeDefined()) даёт ложную уверенностьв покрытии — переопределяется integration-тестом ветки.
update-workspace.dto.ts100 % line, но валидация нового boolean не утверждена (V8 считаетстроки декларации покрытыми при инстанцировании).
sttRealtimeBaseUrlне валидируется как URL при сохранении (только connect-time guard) — расходится с MCP-путём(
mcp-servers.service.ts:138, где есть save-time гейт).deriveRealtimeUrlпри непарсимом base молча уходит наapi.openai.com→ опечатка в self-hosted endpointотправит аудио и workspace-ключ на дефолтный публичный endpoint.
appendAudioмолча отбрасывает PCM до открытия сокета (потеря данных, не баг по дизайну — подтвердить).accвbeforeEach, без sleeps и общего состояния). Для новых хук-тестов — только fake timers, без реальныхsleep.5. Необходимые рефакторинги перед написанием тестов
ai-realtime.service.ts:175— инъекцияwsFactoryвместо хардкодаnew WebSocket(...)openSession/appendAudio/stop/таймеры/testConnection(7 тестов)rawDataToStringincr/decrилиSessionCountersс resetcanConnect(userId, wsId, settings, counts)applyAiSettingPatches/ thin-sliceWorkspaceService.updateaiDictationRealtimefloatToPcm16LE/ resampler / framing изpcm16-worklet.tsв инлайнимый бандлером модульbaseLanguageSubtag/ общийmapGetUserMediaErrorapplyInterimMeta/clampRangeresolveUrl; вынос merge-хелперов чата@vitest/coverage-v8вapps/client6. План внедрения (по фазам)
no-leak (B, рефактор не нужен) +
decrпосле R-GW1;resolveKeyField(F, без рефактора);parseUpstreamEventдобор +
deriveRealtimeUrl(A, без рефактора). Ловит обход авторизации, утечку слотов, утечку ключа, SSRF.openSession/lifecycle/таймеры/testConnection(A).Фиксирует контракт upstream и отсутствие утечек сессий/таймеров.
@vitest/coverage-v8; R-DSP1/2/3 + R-HOOK4 → property/edge-тестыPCM/resample/framing/reducer (D). Самый дешёвый и детерминированный выигрыш покрытия на клиенте.
appendFinalToDraft/interim-transition (F);RealtimeDictationClient/RealtimeMicButton(D).aiDictationRealtime,AiSettingsService.update/resolve,DTO-boolean (C); один ручной mic-smoke (D).
7. Источники
module-testability-analyst(по одному на модуль A–F).jest --coverage --coverageProvider=v8(server, изолированный worktree @0b3d5955)и
vitest run(client); подтверждено 8/8 тестовparseUpstreamEvent, 0 % gateway, 0 % клиентской диктовки,отсутствие
@vitest/coverage-v8.deriveRealtimeUrlоставлен только в A; клиентский payload-контракт сведён к серверному;mapGetUserMediaError— один общий тест) — снято ~4; skip-list (DI-wiring, типы, react-query-обёртки, i18n,AudioWorklet/getUserMedia, snapshot) — отсеяно большинство публичных символов; отклонено как малоценное:
cardStatusLabel-i18n,shouldShowInterim,buildPayloadomit-vs-send, toggle-revert — ~4. Итог отклонено/дедуп: 8.contract 3 (отдельный бакет). Каждый тест называет слой, цель (
файл:строка), сценарии и класс дефекта.1095c5679fto310b54a6da1b9cf7a30ctoc70dac79adPull request closed