diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx
index f0f22ac1..aa209043 100644
--- a/app/(main)/alerts/page.tsx
+++ b/app/(main)/alerts/page.tsx
@@ -467,7 +467,7 @@ const SEVERITY_META: Record
{/* toggle */}
- onToggle(rule.id, v)} disabled={interactionsDisabled||(isLive&&rule.type==="bgp-prefix"&&!rule.enabled)} />
+ onToggle(rule.id, v)} disabled={interactionsDisabled||(isLive&&rule.type==="bgp-prefix"&&!rule.enabled)} />
{/* severity dot */}
@@ -746,7 +746,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled,i
{rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target}
{rule.groupId ? (
- · группа
+ · группа
) : null}
{" · "}{rule.condition}
@@ -968,7 +968,7 @@ function ServerObjectCardPicker({
{loc ? (
{loc}
) : null}
-
+
{SERVER_ROLES_RU[s.type] ?? s.type}
@@ -1122,7 +1122,7 @@ function GreTunnelObjectCardPicker({
{r.comment ? (
-
+
{r.comment}
) : null}
@@ -2559,7 +2559,7 @@ function AlertsContent() {
className={cn(
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
onlyActive
- ? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
+ ? "border-emerald-400 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
)}>
Активных: {active}
@@ -2569,8 +2569,8 @@ function AlertsContent() {
{tg.connected ? "Telegram: Параметры заполнены" : "Telegram: Не настроен"}
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index adcc72c4..6c2b525e 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -22,7 +22,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
К содержимому
-
+
{children}
diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx
index a551ee32..02e9327b 100644
--- a/app/(main)/settings/page.tsx
+++ b/app/(main)/settings/page.tsx
@@ -103,7 +103,7 @@ export default function SettingsPage() {
)}
{backendStatus === true && (
-
+
Доступен
)}
@@ -121,7 +121,7 @@ export default function SettingsPage() {
{/* hint when live but backend unreachable */}
{mode === "live" && backendStatus === false && (
-
+
Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.
diff --git a/components/system-monitor-popover.tsx b/components/system-monitor-popover.tsx
index d0795951..0b372ec3 100644
--- a/components/system-monitor-popover.tsx
+++ b/components/system-monitor-popover.tsx
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { filters, pingProbes, servers } from "@/lib/data"
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
-import { resolveApiUrl } from "@/shared/api/http-client"
+import { getBackendHealth } from "@/shared/api/backend-health"
import {can,ensureAuthConfig} from "@/lib/auth"
import {getSidebarCounts} from "@/shared/api/sidebar-counts"
@@ -66,9 +66,9 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
>
{metric.icon}
- {metric.label}
+ {metric.label}
-
+
{metric.value}
{metric.unit}
@@ -92,7 +92,7 @@ export function SystemMonitorPopover() {
controller?.abort();controller=new AbortController();const request=++revision,signal=controller.signal
const publish=(patch:Partial>)=>{if(cancelled||request!==revision)return;setSnapshot(previous=>({...((previous?.source===source?previous:null)??{source,healthOk:null,counts:null,receivedAt:null,countsError:null}),...patch}))}
await Promise.allSettled([
- (async()=>{try{const response=await fetch(resolveApiUrl(backendUrl,"/health"),{signal:AbortSignal.any([signal,AbortSignal.timeout(3000)]),cache:"no-store"});const raw=await response.json();publish({healthOk:response.ok&&raw?.status==="ok"})}catch{publish({healthOk:false})}})(),
+ (async()=>{try{await getBackendHealth(backendUrl,signal);publish({healthOk:true})}catch{publish({healthOk:false})}})(),
(async()=>{await ensureAuthConfig();if(cancelled||request!==revision)return;if(!can("mm:dashboard:read")){publish({counts:null,countsError:"Нет доступа к сводке"});return}try{const result=await getSidebarCounts(backendUrl,AbortSignal.any([signal,AbortSignal.timeout(30_000)]));if(can("mm:dashboard:read"))publish({counts:result,receivedAt:new Date().toISOString(),countsError:null})}catch{publish({countsError:"Сводка не обновлена; показан последний успешный снимок, если он есть."})}})(),
])
}
@@ -183,7 +183,7 @@ export function SystemMonitorPopover() {
{spiking?"Нет связи":apiOk?"API доступен":"Нет данных"}
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index 83dd1cb2..de4dec84 100644
--- a/docs/ux-ui-plan.md
+++ b/docs/ux-ui-plan.md
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03
-Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. Следующий блок 12.1: общий shell — автоматический аудит доступности/клавиатура/фокус на ПК и320px в обеих темах, согласовать health popover с shared health; затем strict/partial источники dashboard, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
+Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. Следующий блок12.2.1: strict BGP catalog, partial и nullable counters на dashboard; затем остальные источники, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index ccde2c70..a38830d8 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -29,13 +29,21 @@
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
-| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1 принят локально, ограничения ниже |
+## 12.1 — оболочка: контраст, клавиатура и health (2026-10-03)
+
+Подтверждённые нарушения автоматического axe-аудита исправлены на уровнях потребителей: доступный фокус main для skip link; высота toggle правила не менее24px; контраст API badge, подписей групп/Telegram и статуса backend. В открытом системном мониторе значения используют text-foreground, цвет остаётся у значка/полосы; длинная подпись переносится. Установленные ReUI/Base UI primitives и глобальная тема не изменены. Health использует shared getBackendHealth, подтверждённый legacy ok:true согласован с выбором источника; неподтверждённый ответ не становится успехом. axe-core4.11.3 закреплён прямой dev dependency.
+
+Проверки:20 browser сценариев —12 audit комбинаций alerts/settings/releases×320/1440×light/dark,4 открытых monitor/menu, клавиатура skip/apps/monitor с возвратом фокуса, legacy health,2 неподтверждённых health; violations нет. После переноса подписи дополнительно4 открытых слоя.14 профильных layout/settings/release проверок:13 прошли сразу, release fixture встретила transient Windows file lock; исходные bytes восстановлены, ограниченный retry только при записи fixture, повтор всех3 releases прошёл. Typecheck и ESLint изменённых компонентов/тестов прошли. ReUI checklist: сохранены композиции, API props проверены по установленным исходникам; имена/роли, реальные состояния, scroll/security проверены в границах сценариев. Снимки shell-monitor-light/dark-320/1440 добавлены.
+
+Границы: audit охватывает стабильные описанные состояния, не все модалки/формы и не ручную работу screen reader; нет заявления полного соответствия WCAG. Production build и общая регрессия предусмотрены в12.4. Далее12.2.1: строгий BGP catalog на дашборде, partial и nullable counters без подмены нулями; затем остальные источники.
+
## Блок 11Б.7 — сводная локальная приёмка администрирования (2026-10-03)
- 189 browser, 68 context/source, 61 unit/contracts/API и7 named backend profiles прошли. После финальной композиции Alerts дополнительно16 reader/targets/queue и25 form/preview/layout,14 Alerts source прошли. Frontend/contracts production build и backend build прошли; финальная frontend build после композиции тоже прошла. Lint/typecheck профильных изменений прошли.
diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md
index 2d8f1105..aa675957 100644
--- a/docs/ux-ui-stage-12.md
+++ b/docs/ux-ui-stage-12.md
@@ -15,3 +15,16 @@
Клавиатурный сценарий должен предоставлять эквивалент действий мышью ([W3C SC 2.1.1](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)); программные имена/роли/состояния проверяются отдельно ([W3C SC 4.1.2](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)). Проверка reflow включает 320 CSS px; широкие таблицы проверяются как отдельные области прокрутки, а не как разрешение переполнения страницы ([W3C SC 1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)).
Внешние ограничения 11Б сохраняются: migrations0008/0009, PG isolation/CAS/rollback, реальный restore/UDP workers, RouterOS, Telegram, MaxMind/RIPE, Portal/Evolution, CI/Gitea. Processing не разрешается повторять без доказуемой остановки всех отправителей; timeout или checkbox не являются таким доказательством. BGP-prefix evaluator отсутствует и честно обозначен UI; отдельного live GRE расписания нет. Производственный выпуск не разрешён одним успешным browser/DI прогоном.
+
+## 12.1 — оболочка: контраст, клавиатура и health (2026-10-03)
+
+Подтверждённые нарушения автоматического axe-аудита исправлены на уровнях потребителей: доступный фокус main для skip link; высота toggle правила не менее24px; контраст API badge, подписей групп/Telegram и статуса backend. В открытом системном мониторе значения используют text-foreground, цвет остаётся у значка/полосы; длинная подпись переносится. Установленные ReUI/Base UI primitives и глобальная тема не изменены. Health использует shared getBackendHealth, подтверждённый legacy ok:true согласован с выбором источника; неподтверждённый ответ не становится успехом. axe-core4.11.3 закреплён прямой dev dependency.
+
+Проверки:20 browser сценариев —12 audit комбинаций alerts/settings/releases×320/1440×light/dark,4 открытых monitor/menu, клавиатура skip/apps/monitor с возвратом фокуса, legacy health,2 неподтверждённых health; violations нет. После переноса подписи дополнительно4 открытых слоя.14 профильных layout/settings/release проверок:13 прошли сразу, release fixture встретила transient Windows file lock; исходные bytes восстановлены, ограниченный retry только при записи fixture, повтор всех3 releases прошёл. Typecheck и ESLint изменённых компонентов/тестов прошли. ReUI checklist: сохранены композиции, API props проверены по установленным исходникам; имена/роли, реальные состояния, scroll/security проверены в границах сценариев. Снимки shell-monitor-light/dark-320/1440 добавлены.
+
+Границы: audit охватывает стабильные описанные состояния, не все модалки/формы и не ручную работу screen reader; нет заявления полного соответствия WCAG. Production build и общая регрессия предусмотрены в12.4. Далее12.2.1: строгий BGP catalog на дашборде, partial и nullable counters без подмены нулями; затем остальные источники.
+
+| Снимок | ПК | Телефон |
+| --- | --- | --- |
+| Светлый монитор | [1440](ux-ui/screens/shell-monitor-light-1440.png) | [320](ux-ui/screens/shell-monitor-light-320.png) |
+| Тёмный монитор | [1440](ux-ui/screens/shell-monitor-dark-1440.png) | [320](ux-ui/screens/shell-monitor-dark-320.png) |
diff --git a/docs/ux-ui/screens/alerts-overview-1440.png b/docs/ux-ui/screens/alerts-overview-1440.png
index df3d579d..4d10f6ef 100644
Binary files a/docs/ux-ui/screens/alerts-overview-1440.png and b/docs/ux-ui/screens/alerts-overview-1440.png differ
diff --git a/docs/ux-ui/screens/alerts-overview-320.png b/docs/ux-ui/screens/alerts-overview-320.png
index 463ee9c2..d896db14 100644
Binary files a/docs/ux-ui/screens/alerts-overview-320.png and b/docs/ux-ui/screens/alerts-overview-320.png differ
diff --git a/docs/ux-ui/screens/releases-local-1440.png b/docs/ux-ui/screens/releases-local-1440.png
index c7f1aeb4..347a5c56 100644
Binary files a/docs/ux-ui/screens/releases-local-1440.png and b/docs/ux-ui/screens/releases-local-1440.png differ
diff --git a/docs/ux-ui/screens/releases-local-320.png b/docs/ux-ui/screens/releases-local-320.png
index f40b23cd..5a971644 100644
Binary files a/docs/ux-ui/screens/releases-local-320.png and b/docs/ux-ui/screens/releases-local-320.png differ
diff --git a/docs/ux-ui/screens/settings-general-1440.png b/docs/ux-ui/screens/settings-general-1440.png
index 897dbecb..7d0f90f0 100644
Binary files a/docs/ux-ui/screens/settings-general-1440.png and b/docs/ux-ui/screens/settings-general-1440.png differ
diff --git a/docs/ux-ui/screens/settings-general-320.png b/docs/ux-ui/screens/settings-general-320.png
index d3a6ce5c..ea4637d7 100644
Binary files a/docs/ux-ui/screens/settings-general-320.png and b/docs/ux-ui/screens/settings-general-320.png differ
diff --git a/docs/ux-ui/screens/settings-general-390.png b/docs/ux-ui/screens/settings-general-390.png
index f30e492a..a81d1760 100644
Binary files a/docs/ux-ui/screens/settings-general-390.png and b/docs/ux-ui/screens/settings-general-390.png differ
diff --git a/docs/ux-ui/screens/settings-security-1440.png b/docs/ux-ui/screens/settings-security-1440.png
index f2a28465..dc1d9da5 100644
Binary files a/docs/ux-ui/screens/settings-security-1440.png and b/docs/ux-ui/screens/settings-security-1440.png differ
diff --git a/docs/ux-ui/screens/settings-security-320.png b/docs/ux-ui/screens/settings-security-320.png
index d801fb7b..a780662e 100644
Binary files a/docs/ux-ui/screens/settings-security-320.png and b/docs/ux-ui/screens/settings-security-320.png differ
diff --git a/docs/ux-ui/screens/settings-security-390.png b/docs/ux-ui/screens/settings-security-390.png
index 5590904b..90feb8ec 100644
Binary files a/docs/ux-ui/screens/settings-security-390.png and b/docs/ux-ui/screens/settings-security-390.png differ
diff --git a/docs/ux-ui/screens/shell-monitor-dark-1440.png b/docs/ux-ui/screens/shell-monitor-dark-1440.png
new file mode 100644
index 00000000..51e869cc
Binary files /dev/null and b/docs/ux-ui/screens/shell-monitor-dark-1440.png differ
diff --git a/docs/ux-ui/screens/shell-monitor-dark-320.png b/docs/ux-ui/screens/shell-monitor-dark-320.png
new file mode 100644
index 00000000..0b40c639
Binary files /dev/null and b/docs/ux-ui/screens/shell-monitor-dark-320.png differ
diff --git a/docs/ux-ui/screens/shell-monitor-light-1440.png b/docs/ux-ui/screens/shell-monitor-light-1440.png
new file mode 100644
index 00000000..62fe3998
Binary files /dev/null and b/docs/ux-ui/screens/shell-monitor-light-1440.png differ
diff --git a/docs/ux-ui/screens/shell-monitor-light-320.png b/docs/ux-ui/screens/shell-monitor-light-320.png
new file mode 100644
index 00000000..ad71088c
Binary files /dev/null and b/docs/ux-ui/screens/shell-monitor-light-320.png differ
diff --git a/package-lock.json b/package-lock.json
index 591cf0fc..c6d21e45 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,6 +43,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "axe-core": "4.11.3",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
diff --git a/package.json b/package.json
index 4739bef0..58d8055a 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "axe-core": "4.11.3",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
diff --git a/tests/e2e/accessibility-shell.spec.ts b/tests/e2e/accessibility-shell.spec.ts
new file mode 100644
index 00000000..8de92d42
--- /dev/null
+++ b/tests/e2e/accessibility-shell.spec.ts
@@ -0,0 +1,33 @@
+import {test,expect,type Page} from "@playwright/test"
+import axe from "axe-core"
+import {alertsWithRule,emptyOutbox} from "./fixtures/alerts"
+import {evoSettings,evoCatalog} from "./fixtures/evobgp"
+async function setup(page:Page,theme:string){
+ await page.addInitScript(value=>localStorage.setItem("rl-theme",value),theme)
+ await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
+ await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;const body=path==="/api/auth/config"?{required:false}:path==="/api/alerts"?alertsWithRule:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/evobgp/settings"?evoSettings:path==="/api/evobgp/catalog"?evoCatalog:path==="/api/sidebar-counts"?{servers:0,filterRules:0,uptimeProbes:0,uptimeSpeedProbes:0,monitoringItems:0,recursiveRoutes:0}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:path==="/api/servers"||path==="/api/bgp/sessions"?[]:null;return body!==null?r.fulfill({json:body}):r.fulfill({status:503,json:{error:"Fixture unavailable"}})})
+}
+async function settleAnimations(page:Page){await page.evaluate(async()=>{await Promise.allSettled(document.getAnimations().filter(a=>a.effect?.getComputedTiming().iterations!==Infinity).map(a=>a.finished))})}
+for(const theme of ["dark","light"])for(const width of [320,1440])for(const path of ["/alerts","/settings","/releases"])test("Automated WCAG tagged audit/"+theme+"/"+width+path,async({page},info)=>{
+ await setup(page,theme);await page.setViewportSize({width,height:900});await page.goto(path)
+ if(path==="/alerts")await expect(page.getByRole("textbox",{name:"Поиск правил",exact:true})).toBeVisible()
+ else if(path==="/settings"){await expect(page.getByRole("button",{name:"Тёмная",exact:true})).toBeEnabled();await expect(page.getByRole("button",{name:"Скачать",exact:true})).toBeEnabled()}
+ else await expect(page.getByText("Текущая версия",{exact:true})).toBeVisible()
+ await settleAnimations(page);await page.addScriptTag({content:axe.source})
+ const result=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run({include:["#main-content",'[aria-label="Основная навигация"]']},{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})})
+ await info.attach("axe-results",{body:JSON.stringify(result,null,2),contentType:"application/json"})
+ expect(result.violations.map(v=>({id:v.id,impact:v.impact,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([])
+})
+test("Shell keyboard skip link moves focus to main and menus restore focus",async({page})=>{
+ await setup(page,"dark");await page.goto("/releases");await expect(page.getByText("Текущая версия",{exact:true})).toBeVisible();await page.keyboard.press("Tab");const skip=page.getByRole("link",{name:"К содержимому",exact:true});await expect(skip).toBeFocused();await page.keyboard.press("Enter");await expect(page.locator("#main-content")).toBeFocused()
+ const apps=page.getByRole("button",{name:"Приложения",exact:true});await apps.focus();await page.keyboard.press("Enter");await expect(page.getByRole("menu")).toBeVisible();await page.keyboard.press("Escape");await expect(apps).toBeFocused()
+ const monitor=page.getByRole("button",{name:"Монитор системы",exact:true});await monitor.focus();await page.keyboard.press("Enter");await expect(page.locator('[data-slot="popover-content"]')).toBeVisible();await page.keyboard.press("Escape");await expect(monitor).toBeFocused()
+})
+test("System monitor recognizes confirmed legacy health consistently",async({page})=>{await setup(page,"dark");await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("API доступен")})
+
+for(const theme of ["dark","light"])for(const width of [320,1440])test("Open shell layers audit/"+theme+"/"+width,async({page},info)=>{
+ await setup(page,theme);await page.setViewportSize({width,height:900});await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("API доступен");await page.getByRole("button",{name:"Монитор системы",exact:true}).click();await expect(page.locator('[data-slot="popover-content"]')).toBeVisible();await page.addScriptTag({content:axe.source});
+ const result=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run('[data-slot="popover-content"]',{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})});await info.attach("axe-open-monitor",{body:JSON.stringify(result,null,2),contentType:"application/json"});expect(result.violations.map(v=>({id:v.id,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([]);
+ await page.screenshot({path:"docs/ux-ui/screens/shell-monitor-"+theme+"-"+width+".png",animations:"disabled"});await page.keyboard.press("Escape");await page.getByRole("button",{name:"Приложения",exact:true}).click();await expect(page.getByRole("menu")).toBeVisible();await settleAnimations(page);const menu=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run('[role="menu"]',{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})});expect(menu.violations.map(v=>({id:v.id,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([]);
+})
+for(const theme of ["dark","light"])test("Unconfirmed health remains unavailable/"+theme,async({page})=>{await setup(page,theme);await page.route("**/health",r=>r.fulfill({json:{ok:false,status:"unknown"}}));await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("Нет связи")})
diff --git a/tests/e2e/releases-layout.spec.ts b/tests/e2e/releases-layout.spec.ts
index bcce9212..490d658a 100644
--- a/tests/e2e/releases-layout.spec.ts
+++ b/tests/e2e/releases-layout.spec.ts
@@ -15,18 +15,19 @@ for(const width of [320,1440])test("Releases local build is not presented as pub
// Server component reads a local manifest. Restore the original bytes even on assertion failure.
test("Releases malformed metadata is unknown and long confirmed entries remain readable on mobile",async({page})=>{
const {readFileSync,writeFileSync}=await import("node:fs");const file="public/release-manifest.json";const original=readFileSync(file)
+ const writeManifest=async(content:string|Uint8Array)=>{for(let attempt=0;;attempt++){try{writeFileSync(file,content);return}catch(error){if(attempt>=10||!error||typeof error!=="object"||!("code" in error)||!["UNKNOWN","EBUSY","EPERM","EACCES"].includes(String(error.code)))throw error;await new Promise(resolve=>setTimeout(resolve,100))}}}
try{
await page.setViewportSize({width:320,height:900})
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:[]}))
- writeFileSync(file,JSON.stringify({version:"1.0.0",commits:null}));await page.goto("/releases")
+ await writeManifest(JSON.stringify({version:"1.0.0",commits:null}));await page.goto("/releases")
await expect(page.getByRole("alert").filter({hasText:"Метаданные релиза недоступны или повреждены"})).toBeVisible()
await expect(page.getByText("Нет подтверждённых метаданных.",{exact:true})).toBeVisible()
await expect(page.getByRole("link",{name:"Открыть релиз",exact:true})).toHaveCount(0)
const sha="a".repeat(40);const subject="fix(ui): "+"ДлинноеОписание".repeat(20)
- writeFileSync(file,JSON.stringify({version:"1.0.0",tag:"v1.0.0",publishedAt:"2026-10-03T00:00:00Z",releaseUrl:"https://example.test/releases/tag/v1.0.0",commits:[{sha,shortSha:sha.slice(0,7),subject,author:"Fixture",type:"fix"}]}));await page.reload()
+ await writeManifest(JSON.stringify({version:"1.0.0",tag:"v1.0.0",publishedAt:"2026-10-03T00:00:00Z",releaseUrl:"https://example.test/releases/tag/v1.0.0",commits:[{sha,shortSha:sha.slice(0,7),subject,author:"Fixture",type:"fix"}]}));await page.reload()
await expect(page.getByText("Коммиты манифеста v1.0.0: 1.",{exact:true})).toBeVisible()
await expect(page.getByText(subject.replace("fix(ui): ",""),{exact:true})).toBeVisible()
const link=page.getByRole("link",{name:"Открыть релиз",exact:true});await expect(link).toHaveAttribute("href","https://example.test/releases/tag/v1.0.0");await expect(link).toHaveAttribute("rel","noopener noreferrer")
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
- }finally{writeFileSync(file,original)}
+ }finally{await writeManifest(original)}
})