diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 1ec5f60c..2cf2dcf6 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -4,6 +4,7 @@ import probeStyles from "./probe-rows.module.css" import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react" import Link from "next/link" +import { serverOperationError } from "@/lib/server-operations" import { can } from "@/lib/auth" import { useSearchParams } from "next/navigation" import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert" @@ -1055,9 +1056,14 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId: string | null; allProbesHref: string }) { const { mode, backendUrl, backendStatus, checkBackend } = useDataSource() + const [probeSaving, setProbeSaving] = useState(false) + const [probeSaveError, setProbeSaveError] = useState(null) + const savingRef = useRef(false) + const saveContext = useRef(0) const canWrite = can("mm:uptime:write") const [permissionError, setPermissionError] = useState(null) const requireWrite = useCallback(() => { + if (savingRef.current) return false if (can("mm:uptime:write")) { setPermissionError(null); return true } setPermissionError("Недостаточно прав: изменение проб и запуск измерений требуют разрешения на запись мониторинга.") return false @@ -1065,6 +1071,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId /** Live не зависит от ещё не завершённой проверки /health. */ const liveApi = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) + useEffect(() => () => { saveContext.current++ }, [apiFetch, liveApi]) const [overviewState, setOverviewState] = useState<"loading" | "ready" | "error">("loading") const [allServers, setAllServers] = useState([]) @@ -1119,7 +1126,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const overviewReqRef = useRef(0) const loadLiveOverview = useCallback(async () => { - if (!liveApi) return + if (!liveApi || savingRef.current) return const myReq = ++overviewReqRef.current setOpError(null) try { @@ -1127,20 +1134,21 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId apiFetch("/api/servers"), apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"), ]) - if (myReq !== overviewReqRef.current) return + if (myReq !== overviewReqRef.current || savingRef.current) return setAllServers(mapBackendServersToServers(serverRows)) setProbes(data.probes) setResources(data.resources) setOverviewState("ready") } catch (e) { - if (myReq !== overviewReqRef.current) return + if (myReq !== overviewReqRef.current || savingRef.current) return setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime") setOverviewState("error") } }, [apiFetch, liveApi]) const reloadSpeedData = useCallback(async () => { - if (!liveApi) return + if (!liveApi || savingRef.current) return + const context = saveContext.current type RunRow = { id: string srcServerId: string @@ -1166,6 +1174,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"), apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"), ]) + if (context !== saveContext.current || savingRef.current) return setSpeedProbes(sp.probes ?? []) const rows = (runsRes.runs ?? []).map((r) => ({ id: r.id, @@ -1190,6 +1199,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId })) setSpeedRuns(rows) } catch { + if (context !== saveContext.current || savingRef.current) return setSpeedProbes([]) setSpeedRuns([]) } @@ -1467,10 +1477,11 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId })) }, [speedProbes, allServers]) - const persistProbes = useCallback((rows: PingProbe[]) => { + const persistProbes = useCallback(async (rows: PingProbe[]) => { if (!liveApi) return - void apiFetch("/api/uptime/probes", { + await apiFetch("/api/uptime/probes", { method: "PUT", + signal: AbortSignal.timeout(30_000), body: JSON.stringify({ probes: rows.map((p) => ({ id: p.id, @@ -1483,7 +1494,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId showOnDashboard: p.showOnDashboard === true, })), }), - }).catch(() => {}) + }) }, [apiFetch, liveApi]) const toggleDashboardStar = useCallback((id: string) => { @@ -1521,10 +1532,11 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId } }, [probes, liveApi, apiFetch, loadLiveOverview, requireWrite]) - const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => { + const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => { if (!liveApi) return - void apiFetch("/api/uptime/speed-probes", { + await apiFetch("/api/uptime/speed-probes", { method: "PUT", + signal: AbortSignal.timeout(30_000), body: JSON.stringify({ probes: rows.map((p) => ({ id: p.id, @@ -1543,15 +1555,33 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId lastError: p.lastError ?? null, })), }), - }).catch(() => {}) + }) }, [apiFetch, liveApi]) + async function saveProbeForm(save: () => Promise, confirm: () => void) { + if (!requireWrite()) return + savingRef.current = true + setProbeSaving(true); setProbeSaveError(null) + overviewReqRef.current++ + const context = ++saveContext.current + try { + await save() + if (context !== saveContext.current) return + confirm() + } catch (error) { + if (context === saveContext.current) setProbeSaveError(serverOperationError(error)) + } finally { + savingRef.current = false + setProbeSaving(false) + } + } + // ── actions ── const toggleProbe = (id: string, v: boolean) => { if (!requireWrite()) return setProbes((prev) => { const next = prev.map((x) => x.id === id ? { ...x, enabled: v } : x) - persistProbes(next) + void persistProbes(next).catch(error => setOpError(serverOperationError(error))) return next }) } @@ -1565,7 +1595,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId } setProbes((prev) => { const next = prev.filter((x) => x.id !== id) - persistProbes(next) + void persistProbes(next).catch(error => setOpError(serverOperationError(error))) return next }) } @@ -1595,6 +1625,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const openEditProbe = (probe: PingProbe) => { if (!requireWrite()) return + setProbeSaveError(null) setEditingProbeId(probe.id) setNewSrcId(probe.srcServerId) setNewSrcInterface(probe.srcInterface || "") @@ -1606,6 +1637,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const openAddSheet = () => { if (!requireWrite()) return + setProbeSaveError(null) setEditingProbeId(null) setNewSrcId(selectableSources[0]?.id ?? "") setNewSrcInterface("") @@ -1630,31 +1662,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId enabled: true, showOnDashboard: false, } - if (editingProbeId) { - setProbes((prev) => { - const next = prev.map((p) => { - if (p.id !== editingProbeId) return p - return { - ...p, - srcServerId: probeBase.srcServerId, - srcInterface: probeBase.srcInterface, - name: probeBase.name, - target: probeBase.target, - filter: probeBase.filter, - } - }) - persistProbes(next) - return next - }) - } else { - setProbes((prev) => { - const next = [...prev, probeBase] - persistProbes(next) - return next - }) - } - setEditingProbeId(null) - setSheetOpen(false) + const next = editingProbeId ? probes.map(p => p.id === editingProbeId ? { + ...p, srcServerId: probeBase.srcServerId, srcInterface: probeBase.srcInterface, + name: probeBase.name, target: probeBase.target, filter: probeBase.filter, + } : p) : [...probes, probeBase] + void saveProbeForm(() => persistProbes(next), () => { + setProbes(next) + setEditingProbeId(null) + setSheetOpen(false) + }) } const buildSpeedCommand = useCallback((probe: SpeedProbeRow) => { @@ -1876,13 +1892,14 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId } return next }) - persistSpeedProbes(nextRows) + void persistSpeedProbes(nextRows).catch(error => setSpeedError(serverOperationError(error))) return nextRows }) } const openAddSpeedSheet = () => { if (!requireWrite()) return + setProbeSaveError(null) setEditingSpeedProbeId(null) const src = selectableSources[0]?.id ?? "" const dst = selectableSources.find((s) => s.id !== src)?.id ?? src @@ -1904,6 +1921,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const openEditSpeedSheet = (probe: SpeedProbeRow) => { if (!requireWrite()) return + setProbeSaveError(null) setEditingSpeedProbeId(probe.id) setSpeedDraft({ ...probe, @@ -1917,38 +1935,21 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const saveSpeedProbe = () => { if (!requireWrite()) return if (!speedDraft.srcServerId || !speedDraft.dstServerId) return - if (editingSpeedProbeId) { - setSpeedProbes((prev) => { - const next = prev.map((p) => p.id === editingSpeedProbeId - ? { - ...p, - ...speedDraft, - id: editingSpeedProbeId, - durationSec: String(speedDraft.durationSec ?? "10"), - } - : p) - persistSpeedProbes(next) - return next - }) - } else { - setSpeedProbes((prev) => { - const next = [{ - ...speedDraft, - id: `sp-${Date.now()}`, - }, ...prev] - persistSpeedProbes(next) - return next - }) - } - setEditingSpeedProbeId(null) - setSpeedSheetOpen(false) + const next = editingSpeedProbeId ? speedProbes.map(p => p.id === editingSpeedProbeId + ? { ...p, ...speedDraft, id: editingSpeedProbeId, durationSec: String(speedDraft.durationSec ?? "10") } : p) + : [{ ...speedDraft, id: "sp-" + Date.now() }, ...speedProbes] + void saveProbeForm(() => persistSpeedProbes(next), () => { + setSpeedProbes(next) + setEditingSpeedProbeId(null) + setSpeedSheetOpen(false) + }) } const deleteSpeedProbe = (id: string) => { if (!requireWrite()) return setSpeedProbes((prev) => { const next = prev.filter((p) => p.id !== id) - persistSpeedProbes(next) + void persistSpeedProbes(next).catch(error => setSpeedError(serverOperationError(error))) return next }) } @@ -1991,12 +1992,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {tab === "probes" && ( - )} {tab === "speed" && ( - )} @@ -2151,7 +2152,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId

Создайте пробу через кнопку «Новая speed-проба» в шапке страницы

- @@ -2209,7 +2210,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
{/* enable toggle */} - updateSpeedProbe(probe.id, { enabled: v })} /> + updateSpeedProbe(probe.id, { enabled: v })} /> {/* route: src → dst */}
@@ -2240,18 +2241,18 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {/* actions */}
@@ -2513,7 +2514,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId variant="outline" size="sm" className="h-7 gap-1 px-2 text-[11px]" - disabled={!canWrite || !liveApi || groupBusy} + disabled={probeSaving || !canWrite || !liveApi || groupBusy} title={liveApi ? "Ping по всем интерфейсам группы и запись в БД" : "Доступно в режиме Live"} onClick={() => { void refreshProbeGroupPings(srv.id, group) }} > @@ -2547,7 +2548,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId !p.enabled && "opacity-40", )} > - +
toggleDashboardStar(p.id)} + disabled={probeSaving || !canWrite} onClick={() => toggleDashboardStar(p.id)} title={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"} aria-label={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"} aria-pressed={p.showOnDashboard === true} @@ -2628,12 +2629,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
@@ -2677,9 +2678,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId })}
-
{permissionError && {permissionError}} -
+ {probeSaveError && {probeSaveError}} +
)} -
+ - - + @@ -2849,6 +2853,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {/* ── add probe sheet ── */} { + if (savingRef.current) return if (!v) { setSheetOpen(false) setEditingProbeId(null) @@ -2866,14 +2871,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId Ping от выбранного сервера к целевому хосту - {permissionError && {permissionError}} -
+ {probeSaveError && {probeSaveError}} +
{/* from → to visual */}
@@ -2937,17 +2943,17 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId items={filters} /> -
+
- diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 13f39582..1641fa3b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -21,7 +21,7 @@ | 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | -| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; далее ошибки сохранения проб | +| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; далее действия строк | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | @@ -29,6 +29,16 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 7.3В/7.4А — подтверждённое сохранение форм (2026-09-21) + +- Ping/Speed: формы ожидают PUT, обновляют локальный список и закрываются только после успешного ответа. При 400/403/500, сетевой ошибке или таймауте сохраняются поля и показывается ошибка в Sheet. Общий serverOperationError переиспользован для понятного 403 и неопределённого результата timeout. +- Синхронный ref блокирует повторное сохранение до React render; на время запроса заблокированы поля, действия, Отмена и Escape. PUT ограничен 30 секундами. Права повторно проверяются перед сохранением. API по-прежнему заменяет весь список; серверный контракт не менялся. +- GET overview/speed, начатые до сохранения, не применяются после него; чтение пропускается во время сохранения. Поздний ответ записи не применяется после смены mode/backend или размонтирования. Не утверждать полное устранение гонок ранее начатых PATCH/операций строк — это следующий блок. +- Проверки: 25 сценариев uptime-links прошли суммарно, включая 10 новых (400/403/500 и повтор для двух форм, двойное нажатие/Escape, таймаут). В первом прогоне 2 проверки ошибочно проверяли disabled у fieldset; заменены проверкой реального input, оба повторно прошли. TypeScript и production build прошли (34 страницы). ESLint теста чистый; у uptime прежние 1 ошибка set-state-in-effect и 6 предупреждений. +- ReUI audit: сохранены установленные Sheet/Alert, дизайн и тема; fieldset даёт нативную блокировку полей, aria-busy сообщает состояние. HTTP-моки не проверяют реальный JWT/RouterOS. +- Следующий шаг 7.4Б: подтверждение удаления, обновление строки только после API при переключении/удалении, обработка ошибки звезды и блокировка конкурирующих записей. Старые row-handlers пока оптимистичные; их PUT теперь сообщает ошибку вместо её поглощения, но rollback/подтверждение не реализованы. Затем полный URL-контекст мониторинга и приёмка 7.5. +- Контроль квоты: использовано 65% пятичасовой / 87% недельной; локальный коммит без push, сторонние файлы не включать. + ## Блок 7.3Б — ограничения прав мониторинга (2026-09-21) - Uptime использует can("mm:uptime:write") для Ping/Resources/Speed: создание, редактирование, удаление, переключение, звезда дашборда, групповой ping, collect-now и bandwidth-test. Дополнительное создание интерфейса из группы также ограничено. Backend-политика и auth disabled не изменены. diff --git a/docs/ux-ui-stage-7.md b/docs/ux-ui-stage-7.md index 992af5b5..42058701 100644 --- a/docs/ux-ui-stage-7.md +++ b/docs/ux-ui-stage-7.md @@ -61,7 +61,7 @@ URL — источник сохранённого состояния. Back/Forwa 3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие. 4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении. -Следующий исполняемый шаг: блок 7.3В/7.4 — подтверждённое сохранение проб Ping/Speed и ошибки API. Ограничения действий мониторинга 7.3Б выполнены; серверный 403 сохранения ещё не принят из-за прежних optimistic persist-функций. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. +Следующий исполняемый шаг: блок 7.4Б — подтверждение удаления и достоверность действий строк Ping/Speed, включая звезду и конкурирующие записи. Ограничения прав и сохранение обеих форм после API выполнены; ошибки 400/403/500 и timeout сохраняют черновик. Оптимистичные row-handlers ещё требуют доработки. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. ## Решение по синхронизации URL (7.1) diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index e4aad9fc..f26e8f0d 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -186,3 +186,85 @@ for (const speed of [false, true]) { expect(control.writes).toEqual([]) }) } + +for (const speed of [false, true]) { + for (const status of [400, 403, 500]) { + test(`сохранение ${speed ? "Speed" : "Ping"}: ${status} сохраняет черновик, повтор подтверждён`, async ({ page }) => { + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + if (speed) await page.getByRole("button", { name: /Скорость/ }).click() + let fail = true + const bodies: { probes: Record[] }[] = [] + await page.route(speed ? "**/api/uptime/speed-probes" : "**/api/uptime/probes", route => { + if (route.request().method() !== "PUT") return route.fallback() + bodies.push(route.request().postDataJSON()) + return route.fulfill({ status: fail ? status : 200, json: fail ? { error: "Сохранение отклонено" } : { ok: true } }) + }) + await page.getByRole("button", { name: speed ? "Редактировать speed-пробу" : "Редактировать пробу", exact: true }).click() + const dialog = page.getByRole("dialog") + const input = speed ? dialog.locator('input').filter({ visible: true }).last() : dialog.getByPlaceholder("youtube.com") + await input.fill(speed ? "17" : "Черновик проверки") + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(dialog.getByRole("alert")).toContainText(status === 403 ? "Недостаточно прав" : "Сохранение отклонено") + await expect(input).toHaveValue(speed ? "17" : "Черновик проверки") + fail = false + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(dialog).toBeHidden() + expect(bodies).toHaveLength(2) + expect(bodies[1]).toEqual(bodies[0]) + expect(bodies[1].probes[0][speed ? "durationSec" : "name"]).toBe(speed ? 17 : "Черновик проверки") + }) + } + + test(`сохранение ${speed ? "Speed" : "Ping"}: один PUT, Escape и отмена заблокированы до ответа`, async ({ page }) => { + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + if (speed) await page.getByRole("button", { name: /Скорость/ }).click() + let calls = 0 + let release!: () => void + const pending = new Promise(resolve => { release = resolve }) + await page.route(speed ? "**/api/uptime/speed-probes" : "**/api/uptime/probes", async route => { + if (route.request().method() !== "PUT") return route.fallback() + calls++ + await pending + await route.fulfill({ json: { ok: true } }) + }) + await page.getByRole("button", { name: speed ? "Редактировать speed-пробу" : "Редактировать пробу", exact: true }).click() + const dialog = page.getByRole("dialog") + await dialog.getByRole("button", { name: "Сохранить", exact: true }).evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect(dialog.getByRole("button", { name: "Сохранение…", exact: true })).toBeDisabled() + await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled() + await expect(dialog.locator("fieldset input").last()).toBeDisabled() + await page.keyboard.press("Escape") + await expect(dialog).toBeVisible() + expect(calls).toBe(1) + release() + await expect(dialog).toBeHidden() + }) +} + +for (const speed of [false, true]) { + test(`сохранение ${speed ? "Speed" : "Ping"}: таймаут сохраняет форму с неизвестным результатом`, async ({ page }) => { + await page.addInitScript(() => { + const timeout = AbortSignal.timeout.bind(AbortSignal) + AbortSignal.timeout = ms => timeout(ms === 30_000 ? 100 : ms) + }) + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + if (speed) await page.getByRole("button", { name: /Скорость/ }).click() + let release!: () => void + const pending = new Promise(resolve => { release = resolve }) + await page.route(speed ? "**/api/uptime/speed-probes" : "**/api/uptime/probes", async route => { + if (route.request().method() !== "PUT") return route.fallback() + await pending + await route.fulfill({ json: { ok: true } }).catch(() => {}) + }) + await page.getByRole("button", { name: speed ? "Редактировать speed-пробу" : "Редактировать пробу", exact: true }).click() + const dialog = page.getByRole("dialog") + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(dialog.getByRole("alert")).toContainText("Результат операции неизвестен") + await expect(dialog.getByRole("button", { name: "Сохранить", exact: true })).toBeEnabled() + release() + await expect(dialog).toBeVisible() + }) +}