From adb6589816435c64f52727078791075c70d19d5a Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 13:46:12 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=20=D0=B8=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=20=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/certificates/page.tsx | 36 +++++++++++++++---- .../data-grids/certificates-data-grid.tsx | 8 +++-- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 9 ++++- docs/ux-ui-stage-11a.md | 7 ++++ lib/certificates-url-state.test.ts | 14 ++++++++ lib/certificates-url-state.ts | 16 +++++++++ playwright.certificates-context.config.ts | 8 +++++ playwright.config.ts | 2 +- tests/e2e/certificates-context.spec.ts | 13 +++++++ tests/e2e/certificates-url.spec.ts | 36 +++++++++++++++++++ 11 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 lib/certificates-url-state.test.ts create mode 100644 lib/certificates-url-state.ts create mode 100644 playwright.certificates-context.config.ts create mode 100644 tests/e2e/certificates-context.spec.ts create mode 100644 tests/e2e/certificates-url.spec.ts diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx index a579acdc..ecc63236 100644 --- a/app/(main)/certificates/page.tsx +++ b/app/(main)/certificates/page.tsx @@ -1,6 +1,8 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { Suspense, useCallback, useEffect, 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 { FileImportDialog } from "@/components/file-import-dialog" @@ -418,6 +420,10 @@ function CertPartIssueForm({ } export default function CertificatesPage() { + return Загрузка контекста сертификатов…

}>
+} + +function CertificatesSource() { const { mode, backendUrl, prefsHydrated } = useDataSource() if (!prefsHydrated) return

Загрузка настроек источника…

return @@ -428,14 +434,25 @@ function CertificatesContent() { const isLive = prefsHydrated && mode === "live" const liveReady = isLive && backendStatus === true - const [search, setSearch] = useState("") + const params = useSearchParams() + const context = readCertificatesUrl(params) + const search = context.search + const statusFilter = context.status + const selectedServerId = context.server + const urlError = context.error + function writeContext(changes: Record, replace = false) { + const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash + if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href) + } + const setSearch = (value: string) => writeContext({ q: value }, true) + const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value }) + const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value }) const canRead = can("mm:certificates:read") const canWrite = can("mm:certificates:write") const canReadServers = can("mm:servers:read") const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers) const { snapshots, loading, error: loadError, serverError } = catalog const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers]) - const [statusFilter, setStatusFilter] = useState("all") const [issueOpen, setIssueOpen] = useState(false) const [issueStep, setIssueStep] = useState(1) @@ -448,7 +465,6 @@ function CertificatesContent() { const [issueTrustWww, setIssueTrustWww] = useState(true) const [issueTrustApi, setIssueTrustApi] = useState(true) - const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState( "https://acme-v02.api.letsencrypt.org/directory", @@ -467,9 +483,10 @@ function CertificatesContent() { const displayServers = isLive ? serverList : mockServers const scopedCerts = useMemo(() => { + if (urlError) return [] if (selectedServerId === ALL_SERVERS_ID) return displayCerts return displayCerts.filter((c) => c.serverId === selectedServerId) - }, [displayCerts, selectedServerId]) + }, [displayCerts, selectedServerId, urlError]) const certRailItems = useMemo(() => ( displayServers.map((s) => ({ @@ -516,7 +533,7 @@ function CertificatesContent() { const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts]) const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId) - const scopeKnown = !isLive || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null) + const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null)) const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null) const filtered = useMemo(() => { @@ -665,6 +682,9 @@ function CertificatesContent() { } >
+ {!isLive &&

Моковые данные

} + {urlError &&
{urlError}
} + {!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) &&

Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.

} {isLive && backendStatus === false && (
Backend недоступен — live-операции отключены. @@ -734,9 +754,11 @@ function CertificatesContent() { }} countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`} /> - {isLive && scopedCerts.length === 0 && !scopeKnown ?

{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}

: Исправьте контекст ссылки

: isLive && scopedCerts.length === 0 && !scopeKnown ?

{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}

: } diff --git a/components/data-grids/certificates-data-grid.tsx b/components/data-grids/certificates-data-grid.tsx index c7e27aff..a87cda5b 100644 --- a/components/data-grids/certificates-data-grid.tsx +++ b/components/data-grids/certificates-data-grid.tsx @@ -68,9 +68,11 @@ interface CertificatesDataGridProps { certificates: CertificateView[] serverMap: Map isLoading?: boolean + emptyTitle?: string + emptyDescription?: string } -function CertificatesDataGrid({ certificates, serverMap, isLoading }: CertificatesDataGridProps) { +function CertificatesDataGrid({ certificates, serverMap, isLoading, emptyTitle = "Нет сертификатов", emptyDescription = "Выпустите или импортируйте сертификат" }: CertificatesDataGridProps) { const columns = useMemo[]>( () => [ { @@ -229,8 +231,8 @@ function CertificatesDataGrid({ certificates, serverMap, isLoading }: Certificat return ( } - title="Нет сертификатов" - description="Выпустите или импортируйте сертификат" + title={emptyTitle} + description={emptyDescription} className="border-0 py-12" /> ) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index ca599888..6b47a01e 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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А.17.3 — URL-контекст и поздние ответы сертификатов. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.2; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.18 — строгие API ACME/renew/issue, затем формы настроек и polling выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 95bb4bac..1c01a0fc 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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А.17.2; пользователи/контейнеры приняты локально, сертификаты: state каталог подключён; далее 11А.17.3 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.17.3; пользователи/контейнеры приняты локально, сертификаты: state/URL подключены; далее 11А.18 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11А.17.3 — URL и изоляция источника сертификатов (2026-10-01) + +- server/status/q читаются из URL: reload/Back сохраняют контекст, чужие параметры и hash не теряются. Дубликаты, неизвестные статусы, некорректные ID и слишком длинный поиск явно отклоняются; сброс не удаляет чужой контекст. Mock srv ID поддержан отдельно. +- Недоступный сервер не заменяется всеми устройствами; ошибка ссылки скрывает строки. Нет совпадений отличается от подтверждённого пустого каталога. Моковые данные подписаны явно. +- 3 URL unit, 6 URL E2E и 4 E2E смены backend/mode с поздним успехом/ошибкой прошли. ESLint/TypeScript прошли; production сборка URL/Suspense прошла до финального добавления подписи моков, после него повторены TypeScript и E2E. +- Далее 11А.18: строгие ответы/отмена API ACME/renew/issue, затем достоверные формы настроек и issue checkpoint/polling. Настройки, выпуск и общая приёмка сертификатов пока не приняты. + ## Блок 11А.17.2 — state каталог на странице сертификатов (2026-10-01) - Страница читает readCertificatesState и независимо строгий каталог серверов. Снимки сохраняются при общем/частичном отказе; подтверждённое empty очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 442baf63..2df56968 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -213,3 +213,10 @@ - Refresh теперь повторяет строгий GET state, не подмешивает legacy DTO и не объявляет частичный результат полным успехом. Write-действия требуют права сертификатов, выпуск также требует доступного каталога серверов. Неподключённый импорт отключён вместо ложного сообщения об успехе. - 12 профильных HTTP E2E, TypeScript, ESLint и production frontend прошли. API контракт/сетевые правила не менялись. - Следующий блок 11А.17.3: URL server/status/q и проверки позднего ответа при смене mode/backend. Затем отдельные блоки ACME/renew, issue polling, UI/экспорт и приёмка. Сертификаты в целом, резервные копии, 11Б/12 не приняты; реальные RouterOS/ACME/Portal проверки остаются внешними. + +## Блок 11А.17.3 — URL и изоляция источника сертификатов (2026-10-01) + +- server/status/q читаются из URL: reload/Back сохраняют контекст, чужие параметры и hash не теряются. Дубликаты, неизвестные статусы, некорректные ID и слишком длинный поиск явно отклоняются; сброс не удаляет чужой контекст. Mock srv ID поддержан отдельно. +- Недоступный сервер не заменяется всеми устройствами; ошибка ссылки скрывает строки. Нет совпадений отличается от подтверждённого пустого каталога. Моковые данные подписаны явно. +- 3 URL unit, 6 URL E2E и 4 E2E смены backend/mode с поздним успехом/ошибкой прошли. ESLint/TypeScript прошли; production сборка URL/Suspense прошла до финального добавления подписи моков, после него повторены TypeScript и E2E. +- Далее 11А.18: строгие ответы/отмена API ACME/renew/issue, затем достоверные формы настроек и issue checkpoint/polling. Настройки, выпуск и общая приёмка сертификатов пока не приняты. diff --git a/lib/certificates-url-state.test.ts b/lib/certificates-url-state.test.ts new file mode 100644 index 00000000..b3bd0b80 --- /dev/null +++ b/lib/certificates-url-state.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { readCertificatesUrl, patchCertificatesUrl } from "./certificates-url-state" +test("invalid and duplicate context is rejected", () => { + for (const query of ["server=bad", "server=0", "server=-1", "server=1.5", "server=9007199254740992", "server=1&server=2", "status=bad", "status=valid&status=unknown", "q=a&q=b", "q=" + "x".repeat(201)]) assert.ok(readCertificatesUrl(new URLSearchParams(query)).error, query) +}) +test("known context, mock identifier and empty defaults", () => { + assert.deepEqual(readCertificatesUrl(new URLSearchParams("server=2&status=unknown&q=test")), { server: "2", status: "unknown", search: "test", error: null }) + assert.equal(readCertificatesUrl(new URLSearchParams("server=srv1")).error, null) + assert.deepEqual(readCertificatesUrl(new URLSearchParams()), { server: "all", status: "all", search: "", error: null }) +}) +test("patch preserves unrelated parameters and removes filters", () => { + assert.equal(patchCertificatesUrl(new URLSearchParams("server=1&status=valid&ref=map"), { server: null, status: null }), "/certificates?ref=map") +}) diff --git a/lib/certificates-url-state.ts b/lib/certificates-url-state.ts new file mode 100644 index 00000000..ab66aed0 --- /dev/null +++ b/lib/certificates-url-state.ts @@ -0,0 +1,16 @@ +export type CertificateStatusFilter = "all" | "valid" | "expired" | "revoked" | "unknown" +const statuses = ["valid", "expired", "revoked", "unknown"] +export function readCertificatesUrl(params: URLSearchParams) { + const server = params.get("server"), status = params.get("status"), search = params.get("q") ?? "" + let error: string | null = null + if (["server", "status", "q"].some(key => params.getAll(key).length > 1)) error = "Параметры контекста сертификатов повторяются." + if (server !== null && !(/^[1-9][0-9]*$/.test(server) && Number.isSafeInteger(Number(server))) && !/^srv[1-9][0-9]*$/.test(server)) error = "В ссылке указан неверный сервер." + if (status !== null && !statuses.includes(status)) error = "В ссылке указан неизвестный статус." + if (search.length > 200) error = "Поиск в ссылке слишком длинный." + return { server: server ?? "all", status: (status ?? "all") as CertificateStatusFilter, search, error } +} +export function patchCertificatesUrl(params: URLSearchParams, changes: Record) { + const next = new URLSearchParams(params) + for (const [key, value] of Object.entries(changes)) if (value === null || value === "") next.delete(key); else next.set(key, value) + return "/certificates" + (next.size ? "?" + next.toString() : "") +} diff --git a/playwright.certificates-context.config.ts b/playwright.certificates-context.config.ts new file mode 100644 index 00000000..a5ac49ef --- /dev/null +++ b/playwright.certificates-context.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" +const server=base.webServer +if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер") +export default defineConfig({ + ...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"certificates-context.spec.ts", + webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}}, +}) diff --git a/playwright.config.ts b/playwright.config.ts index b79ce9a0..b920558e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/tests/e2e/certificates-context.spec.ts b/tests/e2e/certificates-context.spec.ts new file mode 100644 index 00000000..41e6c258 --- /dev/null +++ b/tests/e2e/certificates-context.spec.ts @@ -0,0 +1,13 @@ +import {test,expect} from "@playwright/test" +import {certificate as container,server,certificateState as state} from "./fixtures/certificates" +const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} +for(const change of ["backend","mode"])for(const fails of [false,true])test("Сертификаты: позднее чтение/"+change+"/"+fails,async({page})=>{ + let started=false,completed=false,release!:()=>void;const gate=new Promise(r=>release=r) + await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:p==="/api/certificates/state"?state():{directoryUrl:"https://acme.example.test/directory",tokenConfigured:false,enabled:false,intervalSec:300,daysBeforeExpiry:30}})}) + await page.route("**/api/certificates/state",async r=>{if(new URL(r.request().url()).port!=="8000")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА сертификатов"}}:{headers,json:state([{...container,name:"СТАРЫЙ СЕРТИФИКАТ"}])})}catch{}finally{completed=true}}) + await page.goto("/certificates");await expect.poll(()=>started).toBe(true);await page.goto("/settings") + if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click() + await page.goto("/certificates");release();await expect.poll(()=>completed).toBe(true) + if(change==="backend")await expect(page.getByText(container.name,{exact:true})).toBeVisible();else await expect(page.getByText("Моковые данные",{exact:true})).toBeVisible() + await expect(page.getByText("СТАРЫЙ СЕРТИФИКАТ",{exact:true})).toHaveCount(0);await expect(page.getByText(/СТАРАЯ ОШИБКА сертификатов/)).toHaveCount(0) +}) diff --git a/tests/e2e/certificates-url.spec.ts b/tests/e2e/certificates-url.spec.ts new file mode 100644 index 00000000..f94a6d04 --- /dev/null +++ b/tests/e2e/certificates-url.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "@playwright/test" +import { certificate, certificateState, server } from "./fixtures/certificates" +test.beforeEach(async ({ page }) => { + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false, enabled: false, intervalSec: 300, daysBeforeExpiry: 30 } }) + }) +}) +for (const query of ["server=bad", "server=1&server=2", "status=bad", "q=a&q=b"]) test("Сертификаты URL: неверный контекст " + query, async ({ page }) => { + await page.goto("/certificates?" + query + "&keep=map#detail") + await expect(page.getByRole("alert", { name: "Контекст сертификатов", exact: true })).toBeVisible() + await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0) + await page.getByRole("button", { name: "Сбросить контекст", exact: true }).click() + expect(new URL(page.url()).search).toBe("?keep=map") + expect(new URL(page.url()).hash).toBe("#detail") + await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() +}) +test("Сертификаты URL: фильтр, reload и Back", async ({ page }) => { + await page.goto("/certificates?server=1&status=unknown&q=certificate&keep=map#detail") + await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + await page.getByRole("button", { name: "Действующие", exact: true }).click() + await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible() + expect(new URL(page.url()).searchParams.get("status")).toBe("valid") + await page.goBack(); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible() + await page.reload(); await expect(page.getByRole("textbox", { name: "Поиск по имени, CN, эмитенту…" })).toHaveValue("certificate") + await expect(page.getByRole("button", { name: "Неизвестные", exact: true })).toHaveAttribute("aria-pressed", "true") + expect(new URL(page.url()).hash).toBe("#detail") +}) +test("Сертификаты URL: недоступный сервер не заменяется всеми", async ({ page }) => { + await page.goto("/certificates?server=2&keep=map") + await expect(page.getByRole("alert").filter({ hasText: "Выбранный сервер недоступен" })).toBeVisible() + await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0) + await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0) + expect(new URL(page.url()).searchParams.get("server")).toBe("2") +})