From 1fba16e1f20c56f2876bba83597974b6718e8192 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 11:42:57 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B8=D0=B7=D0=BE=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20Filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/filters/page.tsx | 36 ++++++--- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 ++- docs/ux-ui-stage-9.md | 4 + tests/e2e/filters-history-lock.spec.ts | 108 +++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/filters-history-lock.spec.ts diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx index 3e0acca3..e50a16ba 100644 --- a/app/(main)/filters/page.tsx +++ b/app/(main)/filters/page.tsx @@ -1426,12 +1426,14 @@ export default function FiltersPage() { }, [isLive, apiFetch]) const loadLiveRules = useCallback(async (serverId: string) => { - if (!isLive || !serverId) return + if (!isLive || !serverId) return false try { const fresh = await apiFetch( `/api/filters/rules?serverId=${encodeURIComponent(serverId)}`, ) - const liveRules = fresh.rulesets.find((r) => r.serverId === serverId)?.rules ?? fresh.rulesets[0]?.rules ?? [] + const ruleset = fresh.rulesets.find(r => r.serverId === serverId) + if (!ruleset || fresh.stale) throw new Error("Не удалось получить актуальные правила выбранного устройства") + const liveRules = ruleset.rules setRulesets((prev) => { const has = prev.some((rs) => rs.serverId === serverId) if (!has) return [...prev, { serverId, rules: liveRules }] @@ -1441,9 +1443,11 @@ export default function FiltersPage() { if (fresh.greTunnels?.length) { setGreByServer((prev) => ({ ...prev, [serverId]: fresh.greTunnels })) } + return true } catch (err) { setLiveStale(true) toast.error("Не удалось прочитать правила с роутера", { description: String(err) }) + return false } }, [isLive, apiFetch]) @@ -1540,7 +1544,8 @@ export default function FiltersPage() { }, [isLive, apiFetch, applyBusy, historyRestoring, liveStale, liveLoadState]) const refreshFromRouter = useCallback(async () => { - if (!isLive || !selectedServerId || applyBusy) return + if (!isLive || !selectedServerId || applyLock.current || applyBusy || historyRestoring) return + applyLock.current = true setApplyBusy(true) try { await loadLiveRules(selectedServerId) @@ -1549,9 +1554,10 @@ export default function FiltersPage() { ensureRecursiveRoutes(selectedServerId), ]) } finally { + applyLock.current = false setApplyBusy(false) } - }, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes]) + }, [isLive, selectedServerId, applyBusy, historyRestoring, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes]) useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive]) @@ -1575,7 +1581,10 @@ export default function FiltersPage() { }, [isLive, selectedServerId, apiFetch]) const restoreRevision = useCallback(async (id: string) => { - if (!isLive || !selectedServerId) return + if (!isLive || !selectedServerId) throw new Error("Выберите устройство в режиме живых данных") + if (applyLock.current || applyBusy || historyRestoring) throw new Error("Дождитесь завершения текущей операции") + applyLock.current = true + setApplyBusy(true) setHistoryRestoring(true) try { const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>( @@ -1586,17 +1595,17 @@ export default function FiltersPage() { if (res.rules) { setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs)) } else { - await loadLiveRules(selectedServerId) + if (!await loadLiveRules(selectedServerId)) throw new Error("Версия применена, но актуальные правила не прочитаны — обновите данные") } setLiveStale(false) - toast.success("Версия применена на роутер") await loadRevisions() - } catch (err) { - throw err + toast.success("Версия применена на роутер") } finally { + applyLock.current = false + setApplyBusy(false) setHistoryRestoring(false) } - }, [isLive, selectedServerId, apiFetch, loadLiveRules, loadRevisions]) + }, [isLive, selectedServerId, apiFetch, applyBusy, historyRestoring, loadLiveRules, loadRevisions]) const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => { const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? [] @@ -1738,7 +1747,7 @@ export default function FiltersPage() { variant="outline" size="sm" onClick={() => void refreshFromRouter()} - disabled={applyBusy} + disabled={applyBusy || historyRestoring} title="Прочитать актуальные правила с роутера" > @@ -1748,10 +1757,11 @@ export default function FiltersPage() { variant="outline" size="sm" onClick={() => { + if (applyLock.current) return setHistoryOpen(true) void loadRevisions() }} - disabled={applyBusy} + disabled={applyBusy || historyRestoring} title="История версий и откат на CHR" > @@ -2000,7 +2010,7 @@ export default function FiltersPage() { open={historyOpen} onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} loadError={historyError} - onRetry={() => { void loadRevisions() }} + onRetry={() => { if (!applyLock.current) void loadRevisions() }} title="История фильтров" itemLabel="правил" revisions={revisions} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 54bb36d9..3031f6af 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -88,7 +88,7 @@ flowchart LR Ближайший оставшийся объём: -1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-список меняется после подтверждения. В 9.36 копирование Filters ждёт подтверждения, сохраняет цель/правила/режим при ошибке и блокирует повторы/закрытие. В 9.37 удаление Filters перенесено в диалог с сохранением ошибки, подтверждением API и защитой повторов. В 9.38 перемещение Filters подтверждается API; поиск использует полный порядок для приоритета и границ, действия таблицы блокируются на время операции. Далее — общая блокировка восстановления/обновления; загрузка, схемы и гонки Filters остаются открытыми; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-список меняется после подтверждения. В 9.36 копирование Filters ждёт подтверждения, сохраняет цель/правила/режим при ошибке и блокирует повторы/закрытие. В 9.37 удаление Filters перенесено в диалог с сохранением ошибки, подтверждением API и защитой повторов. В 9.38 перемещение Filters подтверждается API; поиск использует полный порядок для приоритета и границ, действия таблицы блокируются на время операции. В 9.39 обновление/восстановление Filters используют общий замок до чтения правил/истории; ошибка чтения сохраняет подтверждение, чужой ruleset и stale отклоняются. Далее — схемы и первоначальная загрузка без подмены моками, затем гонки и свежесть Filters; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 74ea45b1..29741cfa 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,20 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.38 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.39 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.39 — общий замок обновления и восстановления Filters (2026-09-30) + +- refreshFromRouter и restoreRevision используют общий синхронный applyLock. Восстановление удерживает его до чтения правил (если ответ не содержит rules) и истории. Повторы/конфликты отклоняются, выбор устройства и действия таблицы удерживаются; finally освобождает замок после успеха/ошибки. Открытие/повтор загрузки истории также проверяют замок. +- loadLiveRules возвращает признак успешного чтения и требует ruleset выбранного serverId, без подстановки первого чужого набора; stale отклоняется. При отказе сохраняется последний список и readonly-состояние. Восстановление с ok=true без правил, но неудачным чтением, сохраняет подтверждение с сообщением «Версия применена, но актуальные правила не прочитаны» и не показывает уведомление полного успеха. После успешного чтения данные снова доступны для операций. При ошибке самой истории её существующий alert остаётся; подтверждение применения относится к ответу restore и данным, а не к доступности каталога ревизий. +- Прошли 15 разных E2E в нескольких прогонах: девять прежних операций/таблицы, два прежних сценария истории, три восстановления (403, HTTP/чужой ruleset/stale при повторном чтении, задержка чтения/истории, двойное нажатие, disabled конфликтов, освобождение замка) и двойное обновление с ошибкой/повтором. Первые прогоны новых тестов исправлены по селекторам: два alert в подтверждении и фоновые кнопки, скрытые модальным диалогом от accessibility tree. Финальные четыре профильных теста прошли. TypeScript и lint нового теста прошли; исходные 2 ошибки/13 предупреждений страницы совпадают с HEAD. Последние build: frontend — 9.31, backend — 9.32, не повторялись. +- Следующий точный шаг: схемы ответов Filters и достоверность первоначальной загрузки — убрать подмену live-ошибки встроенными моками, сохранять реальные последние данные, различать начальную ошибку и пустой список, дать повтор. Затем поколения чтения/смены backend/режима и свежесть; маппинг шлюзов/реальные RouterOS/JWT/БД ещё требуют приёмки. Структура payload rules пока не валидируется, поэтому malformed rules остаются риском. Этап 9 открыт. +- Квота: 66% пятичасовой/10% недельной на старте, 68%/11% на последней проверке перед журналированием. Локальный коммит без push; чужие изменения сохранены. + ## Блок 9.38 — перемещение Filters и порядок в поиске (2026-09-30) - Перемещение работает по ID в полном наборе устройства, проверяет обе границы и ожидает applyRules. До ok=true порядок не меняется; отказ сохраняет порядок и показывает ошибку над таблицей. Синхронный moveLock исключает повторы, status сообщает ожидание; общий applyLock сохраняется. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 164b0ba3..63340ea6 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -193,3 +193,7 @@ Backend сообщает failures по срезу, неполный snapshot о ## Блок 9.38 — перемещение Filters (2026-09-30) Перемещение по ID ждёт подтверждения и сохраняет порядок/ошибку при отказе. Приоритет и границы поиска берутся из полного списка. Действия таблицы блокируются при операции, повторы защищены синхронным замком. Девять E2E и TypeScript прошли; новых lint замечаний нет. Далее — общий замок восстановления/обновления, затем схемы/гонки/достоверность загрузки. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32. + +## Блок 9.39 — общий замок Filters (2026-09-30) + +Обновление/восстановление используют applyLock, восстановление держит его до чтения правил/истории. Ошибка чтения сохраняет подтверждение и последние строки; чужой ruleset и stale отклоняются. 15 разных E2E в нескольких прогонах и TypeScript прошли; новых lint замечаний нет. Далее — схемы/первоначальная загрузка без моковой подмены, затем гонки и свежесть. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32. diff --git a/tests/e2e/filters-history-lock.spec.ts b/tests/e2e/filters-history-lock.spec.ts new file mode 100644 index 00000000..29ca049e --- /dev/null +++ b/tests/e2e/filters-history-lock.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "@playwright/test" + +for (const failure of ["http", "wrong-server", "stale"] as const) { + test(`Filters: замок восстановления и отказ чтения ${failure}`, async ({ page }) => { + const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Правило" } + let restores = 0, revisionReads = 0, readsAfterRestore = 0 + let releaseRead: (() => void) | undefined, releaseHistory: (() => void) | undefined + const readGate = new Promise(resolve => { releaseRead = resolve }) + const historyGate = new Promise(resolve => { releaseHistory = resolve }) + let historyWaiting = false + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", async route => { + const path = new URL(route.request().url()).pathname + if (path.endsWith("/restore")) { + restores++ + expect(route.request().postDataJSON()).toEqual({ serverId: "1" }) + if (restores === 1) return route.fulfill({ status: 403, json: { error: "Откат запрещён тест" } }) + return route.fulfill({ json: { ok: true } }) + } + if (path === "/api/filters/revisions") { + revisionReads++ + if (restores === 3) { historyWaiting = true; await historyGate } + return route.fulfill({ json: { revisions: [{ id: "rev/1", serverId: "1", section: "filters", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] } }) + } + if (path === "/api/filters/rules" && restores >= 2) { + readsAfterRestore++ + if (restores === 2) { + if (failure === "http") return route.fulfill({ status: 503, json: { error: "Чтение недоступно тест" } }) + return route.fulfill({ json: { rulesets: [{ serverId: failure === "wrong-server" ? "2" : "1", rules: [] }], greTunnels: [], stale: failure === "stale" } }) + } + await readGate + } + return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true } + : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }] + : path === "/api/auth/config" ? { required: false } + : path === "/api/filters/gre-tunnels" ? { tunnels: [] } + : path === "/api/recursive-routes" ? { routes: [] } : {} }) + }) + await page.goto("/filters") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + await page.getByRole("button", { name: "История", exact: true }).click() + const sheet = page.getByRole("dialog") + await sheet.getByRole("button", { name: "Откатить", exact: true }).click() + const confirm = page.getByRole("alertdialog") + const restore = confirm.getByRole("button", { name: "Откатить", exact: true }) + await restore.click() + await expect(confirm.getByRole("alert").filter({ hasText: "Откат запрещён тест" })).toBeVisible() + await restore.click() + await expect(confirm.getByRole("alert").filter({ hasText: "Версия применена, но актуальные правила не прочитаны" })).toBeVisible() + await expect(rows).toHaveCount(1) + await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0) + await expect(restore).toBeEnabled() + await restore.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect.poll(() => restores).toBe(3) + await expect.poll(() => readsAfterRestore).toBe(2) + await expect(restore).toBeDisabled() + await expect(confirm.getByRole("button", { name: "Отмена" })).toBeDisabled() + for (const name of ["Обновить", "История", "Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true, includeHidden: true })).toBeDisabled() + await page.keyboard.press("Escape") + await expect(confirm).toBeVisible() + releaseRead!() + await expect.poll(() => historyWaiting).toBe(true) + await expect(restore).toBeDisabled() + await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeDisabled() + await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0) + releaseHistory!() + await expect(confirm).toHaveCount(0) + await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeEnabled() + await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeEnabled() + expect(restores).toBe(3) + expect(revisionReads).toBe(2) + }) +} + +test("Filters: двойное обновление удерживает общий замок и освобождает его после ошибки", async ({ page }) => { + let deviceReads = 0 + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Правило" } + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", async route => { + const url = new URL(route.request().url()), path = url.pathname + if (path === "/api/filters/rules" && url.searchParams.has("serverId")) { + deviceReads++ + if (deviceReads === 2) { await gate; return route.fulfill({ status: 503, json: { error: "Сбой обновления тест" } }) } + } + return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true } + : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }] + : path === "/api/auth/config" ? { required: false } + : path === "/api/filters/gre-tunnels" ? { tunnels: [] } + : path === "/api/recursive-routes" ? { routes: [] } : {} }) + }) + await page.goto("/filters") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await expect.poll(() => deviceReads).toBe(1) + const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true }) + await refresh.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect.poll(() => deviceReads).toBe(2) + for (const name of ["Обновить", "История", "Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true, includeHidden: true })).toBeDisabled() + release!() + await expect(refresh).toBeEnabled() + await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeDisabled() + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await refresh.click() + await expect.poll(() => deviceReads).toBe(3) + await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeEnabled() +})