fix(ui): проверить доступность и адаптивность сертификатов

This commit is contained in:
Denozordec committed 2026-10-01 14:42:11 +07:00
1 parent f5a71728ac
commit 16fa6eb5d8
17 files changed
+101 -37

No files matched your search

+24 -22
View File
@@ -4,7 +4,7 @@ import { Suspense, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
@@ -77,10 +77,10 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
<div className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-4 py-3 text-sm">
<AlertCircleIcon className="size-5 text-destructive shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-600 dark:text-red-400">
<p className="font-medium text-destructive">
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
</p>
<p className="text-muted-foreground text-xs mt-0.5">
@@ -93,10 +93,10 @@ function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex items-start gap-3 rounded-lg bg-warning/5 border border-warning/20 px-4 py-3 text-sm">
<AlertTriangleIcon className="size-5 text-warning shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-600 dark:text-amber-400">
<p className="font-medium text-warning">
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
</p>
<p className="text-muted-foreground text-xs mt-0.5">
@@ -112,11 +112,13 @@ function CertPartKpi({
expiring,
expired,
complete,
totalKnown,
}: {
displayCerts: CertificateView[]
expiring: CertificateView[]
expired: CertificateView[]
complete: boolean
totalKnown: boolean
}) {
return (
<KpiStatGrid
@@ -125,7 +127,7 @@ function CertPartKpi({
{
id: "all",
label: "Всего",
value: complete ? displayCerts.length : "—",
value: totalKnown ? displayCerts.length : "—",
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
@@ -508,27 +510,27 @@ function CertificatesContent() {
/>
}
>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-5 min-w-0">
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
{isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
<div className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
Backend недоступен — live-операции отключены.
</div>
)}
{loadError && isLive && (
<div role="alert" aria-label="Каталог сертификатов" className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<div role="alert" aria-label="Каталог сертификатов" className="[overflow-wrap:anywhere] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
</div>
)}
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов" className="[overflow-wrap:anywhere]">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
{isLive && !canRead && <p role="status">Нет права mm:certificates:read.</p>}
{isLive && !canReadServers && <p role="status">Нет права mm:servers:read. Выбор устройства для выпуска недоступен.</p>}
{isLive && catalog.collectedAt && <p className="text-xs text-muted-foreground">Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}</p>}
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot => <div key={snapshot.latest.serverId}>
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId}>{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId} className="[overflow-wrap:anywhere]">{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
</div>)}
@@ -540,7 +542,7 @@ function CertificatesContent() {
)}
{!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} />
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} totalKnown={scopeKnown} />
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
@@ -551,17 +553,17 @@ function CertificatesContent() {
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, CN, эмитенту…"
segmented={{
value: statusFilter,
onChange: setStatusFilter,
options: [
filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: "all", label: "Все" },
{ value: "valid", label: "Действующие" },
{ value: "expired", label: "Истёкшие" },
{ value: "revoked", label: "Отозванные" },
{ value: "unknown", label: "Неизвестные" },
],
}}
]}
/></div>}
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
/>
{urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
@@ -578,7 +580,7 @@ function CertificatesContent() {
</ServerRailLayout>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Выпуск сертификата</SheetTitle>
<SheetDescription>
@@ -586,7 +588,7 @@ function CertificatesContent() {
</SheetDescription>
</SheetHeader>
<div className="px-6 pt-3 text-sm">
<div className="px-6 pt-3 text-sm min-w-0 [overflow-wrap:anywhere]">
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
@@ -4,13 +4,13 @@ import type { CertificateView } from "@/lib/certificate-view"
function CertificateExpandedDetail({ cert }: { cert: CertificateView }) {
return (
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
<div className="min-w-0 [overflow-wrap:anywhere] px-5 sm:px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
<div>
<p className="text-muted-foreground mb-1">Размер ключа</p>
<p className="font-mono font-medium">{cert.keySize === null ? "Неизвестен" : cert.keySize + " бит"}</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Действителен с</p>
<p className="text-muted-foreground mb-1">Начало срока</p>
<p className="font-mono">{cert.validFrom ?? "Дата неизвестна"}</p>
</div>
<div>
@@ -31,6 +31,8 @@ function CertificateExpandedDetail({ cert }: { cert: CertificateView }) {
{cert.trusted === null ? "Неизвестно" : cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
</p>
</div>
{"rawValidFrom" in cert && !cert.validFrom && cert.rawValidFrom && <p className="col-span-2 sm:col-span-4 text-muted-foreground">Начало срока не удалось определить. Значение RouterOS: <span className="font-mono">{cert.rawValidFrom}</span></p>}
{"rawValidUntil" in cert && !cert.validUntil && cert.rawValidUntil && <p className="col-span-2 sm:col-span-4 text-muted-foreground">Окончание срока не удалось определить. Значение RouterOS: <span className="font-mono">{cert.rawValidUntil}</span></p>}
</div>
)
}
@@ -38,3 +38,10 @@ test("expired and positive observed days stay distinct", () => {
assert.match(renderToStaticMarkup(<CertDaysCell cert={{ ...cert, daysLeft: -3 }} />), /Истёк 3д назад/)
assert.match(renderToStaticMarkup(<CertDaysCell cert={{ ...cert, daysLeft: 40 }} />), /40д осталось/)
})
test("unparsed RouterOS dates remain explicit raw values without claiming valid dates", () => {
const details = renderToStaticMarkup(<CertificateExpandedDetail cert={{ ...cert, rawValidFrom: "oct/01/2026 12:00:00", rawValidUntil: "oct/01/2027 12:00:00" }} />)
assert.match(details, /Начало срока не удалось определить/)
assert.match(details, /Окончание срока не удалось определить/)
assert.ok(details.includes("oct/01/2026 12:00:00"))
})
@@ -10,6 +10,7 @@ import {
import type { Server } from "@/lib/data"
import type { CertificateView } from "@/lib/certificate-view"
import { CertDaysCell } from "@/components/data-grids/certificate-days-cell"
import { Button } from "@/components/ui/button"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
@@ -40,27 +41,27 @@ const STATUS_CONFIG: Record<
> = {
unknown: {
label: "Неизвестен",
icon: <CircleHelpIcon className="size-4 text-muted-foreground" />,
icon: <CircleHelpIcon aria-hidden="true" className="size-4 text-muted-foreground" />,
badge: "bg-muted text-muted-foreground border-border",
row: "",
},
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
icon: <BadgeCheckIcon aria-hidden="true" className="size-4 text-success" />,
badge: "bg-success/10 text-success border-success/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
icon: <ShieldOffIcon aria-hidden="true" className="size-4 text-destructive" />,
badge: "bg-destructive/10 text-destructive border-destructive/20",
row: "bg-destructive/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
icon: <ShieldAlertIcon aria-hidden="true" className="size-4 text-warning" />,
badge: "bg-warning/10 text-warning border-warning/20",
row: "bg-warning/5",
},
}
@@ -87,11 +88,13 @@ function CertificatesDataGrid({ certificates, serverMap, isLoading, emptyTitle =
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
<Button type="button" variant="ghost" size="icon" className="size-11 sm:size-8 shrink-0" aria-label={"Детали сертификата " + cert.name} aria-expanded={expanded} onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
)}
</Button>
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{cfg.icon}</span>
+1 -1
View File
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
Актуальная точка продолжения: 11А.22 — визуальная и профильная приёмка сертификатов, затем резервные копии. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
Актуальная точка продолжения: 11А.23 — достоверность результата issue runner, затем окончательная приёмка сертификатов и резервные копии. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
+10 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.21.4; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.22 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.22; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.23 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,15 @@
## Блок 11А.22 — визуальная и профильная приёмка UI сертификатов (2026-10-01)
- Состояния таблицы/предупреждений используют semantic success/warning/destructive. Раскрытие деталей имеет native кнопку с именем, aria-expanded и клавиатурой; исходные нераспознанные даты RouterOS показываются явно без подмены нормализованной датой.
- Общее количество остаётся известным при неизвестных статусах/сроках, отдельные классификации показывают —. Мобильный фильтр сохраняет подписи в горизонтальном контейнере; Sheet использует full width через data-side вариант, footer переносится. Vendor/theme не изменены.
- 9 PNG (обзор/детали/форма × ПК/390/320) сохранены и просмотрены. Layout проверки включают отсутствие горизонтального overflow страницы/формы, клавиатурное раскрытие, поиск и возврат фокуса после Escape.
- Профильная регрессия: 48 main E2E + 16 context E2E, 26 frontend unit, backend test:certificates, frontend/backend production build, TypeScript/профильный ESLint и ReUI checklist прошли. Реальные PostgreSQL/ACME/RouterOS/S3 не проверялись.
- UI сертификатов принят в этих локальных границах. При сверке runner найден отдельный дефект: ошибка appendEvent после успешного выпуска попадает в общий catch и записывает failed; fire-and-forget onStep может перезаписать terminal шаг.
- **Следующий блок 11А.23:** отделить события/ошибки метаданных runner от результата реальной операции, упорядочить onStep записи, защитить terminal от повторного запуска и cleanup lock при setup ошибке; fault unit/backend checks. Затем метаданные JSON snapshot/окончательная приёмка сертификатов и резервные копии.
## Блок 11А.21.4 — сохраняемая задача выпуска на странице (2026-10-01)
- До POST создаётся requestId и immutable цель, записывается checkpoint sessionStorage по backend URL. Невозможность первоначальной записи не отправляет выпуск. Reload восстанавливает ту же задачу; моковый режим не показывает live checkpoint. Повреждённая запись блокирует новый выпуск.
+17
View File
@@ -272,3 +272,20 @@
- При unmount отменяются POST/poll/задержки; поздние успех/ошибка не меняют другой backend/mode и не показывают старый toast.
- 3 checkpoint + 5 poll unit, 7 issue E2E, 8 source/context E2E, TypeScript/ESLint и production frontend прошли. Реальный PostgreSQL/ACME/RouterOS выпуск не проверен; pending job восстановление после перезапуска backend требует отдельной инфраструктурной приёмки.
- Следующий блок 11А.22: семантические токены, клавиатура/ПК/390/320, снимки и профильная регрессия сертификатов. Импорт/экспорт приватного ключа остаются явно недоступны; безопасный JSON снимок каталога можно добавить отдельно. Затем резервные копии и 11Б/12.
## Блок 11А.22 — визуальная и профильная приёмка UI сертификатов (2026-10-01)
- Состояния таблицы/предупреждений используют semantic success/warning/destructive. Раскрытие деталей имеет native кнопку с именем, aria-expanded и клавиатурой; исходные нераспознанные даты RouterOS показываются явно без подмены нормализованной датой.
- Общее количество остаётся известным при неизвестных статусах/сроках, отдельные классификации показывают —. Мобильный фильтр сохраняет подписи в горизонтальном контейнере; Sheet использует full width через data-side вариант, footer переносится. Vendor/theme не изменены.
- 9 PNG (обзор/детали/форма × ПК/390/320) сохранены и просмотрены. Layout проверки включают отсутствие горизонтального overflow страницы/формы, клавиатурное раскрытие, поиск и возврат фокуса после Escape.
- Профильная регрессия: 48 main E2E + 16 context E2E, 26 frontend unit, backend test:certificates, frontend/backend production build, TypeScript/профильный ESLint и ReUI checklist прошли. Реальные PostgreSQL/ACME/RouterOS/S3 не проверялись.
- UI сертификатов принят в этих локальных границах. При сверке runner найден отдельный дефект: ошибка appendEvent после успешного выпуска попадает в общий catch и записывает failed; fire-and-forget onStep может перезаписать terminal шаг.
- **Следующий блок 11А.23:** отделить события/ошибки метаданных runner от результата реальной операции, упорядочить onStep записи, защитить terminal от повторного запуска и cleanup lock при setup ошибке; fault unit/backend checks. Затем метаданные JSON snapshot/окончательная приёмка сертификатов и резервные копии.
### Экраны сертификатов
| Область | Проверенные изображения | Схема |
| --- | --- | --- |
| Обзор | [ПК](screens/certificates-overview-desktop.png), [390](screens/certificates-overview-mobile.png), [320](screens/certificates-overview-narrow.png) | Свежесть → известный итог / неизвестные классификации → настройки → таблица |
| Детали | [ПК](screens/certificates-details-desktop.png), [390](screens/certificates-details-mobile.png), [320](screens/certificates-details-narrow.png) | Фильтр → native раскрытие → поля и unknown → исходные даты |
| Выпуск | [ПК](screens/certificates-issue-desktop.png), [390](screens/certificates-issue-mobile.png), [320](screens/certificates-issue-narrow.png) | Цель → проверка → requestId → та же задача → подтверждение API |
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+24
View File
@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test"
import { certificateRoutes, openIssue } from "./fixtures/certificate-issue"
import { certificate, certificateState } from "./fixtures/certificates"
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Сертификаты: клавиатура и адаптивность " + name, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await certificateRoutes(page)
const long = { ...certificate, name: "certificate-with-a-very-long-name-for-narrow-screens", commonName: "very-long-subdomain-for-responsive-layout.example.test", sans: ["very-long-alternative-domain-for-responsive-layout.example.test"] }
await page.route("**/api/certificates/state", r => r.fulfill({ json: certificateState([long]) }))
await page.goto("/certificates"); await expect(page.getByText(long.name, { exact: true })).toBeVisible()
const summary = page.getByLabel("Сводка сертификатов", { exact: true }); await expect(summary).toContainText("1")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
const detail = page.getByRole("button", { name: "Детали сертификата " + long.name, exact: true }); await detail.focus(); await page.keyboard.press("Enter")
await expect(detail).toHaveAttribute("aria-expanded", "true"); await expect(page.getByText("Доверие", { exact: true })).toBeVisible()
await page.screenshot({ path: "docs/ux-ui/screens/certificates-details-" + name + ".png", fullPage: true })
await page.evaluate(() => { for (const element of document.querySelectorAll<HTMLElement>("*")) if (element.scrollTop > 0) element.scrollTop = 0 })
await page.screenshot({ path: "docs/ux-ui/screens/certificates-overview-" + name + ".png", fullPage: true })
await page.keyboard.press("Enter"); await expect(detail).toHaveAttribute("aria-expanded", "false")
const search = page.getByRole("textbox", { name: "Поиск по имени, CN, эмитенту…" }); await search.fill("нет-совпадений"); await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible(); await search.fill("")
const dialog = await openIssue(page)
await expect.poll(async () => { const box = await dialog.boundingBox(); return !!box && box.x >= 0 && box.x + box.width <= width + 1 && (width >= 640 || box.width >= width - 2) }).toBe(true)
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/certificates-issue-" + name + ".png", fullPage: true })
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0)
await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeFocused()
})