fix(ui): изолировать операции восстановления Filters
This commit is contained in:
1 parent
7554f1804b
commit
1fba16e1f2
5 files changed
+145
-15
No files matched your search
+23
-13
@@ -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<LiveFiltersResponse>(
|
||||
`/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="Прочитать актуальные правила с роутера"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", applyBusy && "animate-spin")} />
|
||||
@@ -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"
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
@@ -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}
|
||||
|
||||
+1
-1
@@ -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-спLine truncated
|
||||
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-спLine truncated
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
@@ -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 сохраняется.
|
||||
|
||||
@@ -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.
|
||||
@@ -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<void>(resolve => { releaseRead = resolve })
|
||||
const historyGate = new Promise<void>(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<void>(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()
|
||||
})
|
||||
Reference in new issue
Block a user