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)} })