fix(ui): исправить доступность оболочки и проверку состояния сервера

This commit is contained in:
Denozordec committed 2026-10-03 22:49:52 +07:00
1 parent 1a9966ec95
commit c343ce62c8
25 files changed
+78 -21

No files matched your search

+8 -8
View File
@@ -467,7 +467,7 @@ const SEVERITY_META: Record<AlertSeverity, { label: string; dot: string; badge:
label: "Критическое",
dot: "bg-red-500",
badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
chip: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
chip: "border-red-400 bg-red-500/10 text-red-700 dark:text-red-400",
},
warning: {
label: "Предупреждение",
@@ -726,7 +726,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled,i
!rule.enabled && "opacity-55",
)}>
{/* toggle */}
<FormToggle ariaLabel={"Включить правило "+rule.name} checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled||(isLive&&rule.type==="bgp-prefix"&&!rule.enabled)} />
<FormToggle className="h-6" ariaLabel={"Включить правило "+rule.name} checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled||(isLive&&rule.type==="bgp-prefix"&&!rule.enabled)} />
{/* severity dot */}
<SeverityDot severity={rule.severity} />
@@ -746,7 +746,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled,i
{rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target}
</span>
{rule.groupId ? (
<span className="text-muted-foreground/80"> · группа</span>
<span className="text-muted-foreground"> · группа</span>
) : null}
{" · "}{rule.condition}
</p>
@@ -968,7 +968,7 @@ function ServerObjectCardPicker({
{loc ? (
<p className="text-[10px] text-muted-foreground/90 truncate">{loc}</p>
) : null}
<p className="text-[9px] text-muted-foreground/80">
<p className="text-[9px] text-muted-foreground">
{SERVER_ROLES_RU[s.type] ?? s.type}
</p>
</div>
@@ -1122,7 +1122,7 @@ function GreTunnelObjectCardPicker({
</span>
</div>
{r.comment ? (
<p className="text-[9px] text-muted-foreground/80 line-clamp-2 mt-1 pl-5 border-t border-border/30 pt-1 leading-snug">
<p className="text-[9px] text-muted-foreground line-clamp-2 mt-1 pl-5 border-t border-border/30 pt-1 leading-snug">
{r.comment}
</p>
) : 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() {
<span className={cn(
"ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
tg.connected
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
? "border-emerald-400 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
: "border-red-400 bg-red-500/10 text-red-700 dark:text-red-400",
)}>
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
{tg.connected ? "Telegram: Параметры заполнены" : "Telegram: Не настроен"}
+1 -1
View File
@@ -22,7 +22,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
+2 -2
View File
@@ -103,7 +103,7 @@ export default function SettingsPage() {
</span>
)}
{backendStatus === true && (
<span className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<span className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400">
<CheckIcon className="size-3.5" />Доступен
</span>
)}
@@ -121,7 +121,7 @@ export default function SettingsPage() {
{/* hint when live but backend unreachable */}
{mode === "live" && backendStatus === false && (
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
<div className="flex items-start gap-2 py-3 text-xs text-amber-700 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
</div>
+5 -5
View File
@@ -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}
</div>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
<span className="text-muted-foreground min-w-0 break-words text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
<span className="text-foreground shrink-0 text-xs font-semibold tabular-nums">
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
@@ -92,7 +92,7 @@ export function SystemMonitorPopover() {
controller?.abort();controller=new AbortController();const request=++revision,signal=controller.signal
const publish=(patch:Partial<NonNullable<typeof snapshot>>)=>{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() {
<Badge
variant={spiking ? "destructive-light" : apiOk?"success-light":"outline"}
size="xs"
className="h-4 px-1.5 text-[10px]"
className={cn("h-4 px-1.5 text-[10px]",apiOk&&"text-emerald-700 dark:text-emerald-400",spiking&&"text-red-700 dark:text-red-400")}
>
{spiking?"Нет связи":apiOk?"API доступен":"Нет данных"}
</Badge>
+1 -1
View File
@@ -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).
+9 -1
View File
@@ -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 профильных изменений прошли.
+13
View File
@@ -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) |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+1
View File
@@ -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",
+1
View File
@@ -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",
+33
View File
@@ -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("Нет связи")})
+4 -3
View File
@@ -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)}
})