fix(ui): подтверждать сохранение пользователей IPsec
This commit is contained in:
1 parent
f9413d5100
commit
86ecf764ff
7 files changed
+135
-25
No files matched your search
@@ -398,10 +398,6 @@ export default function IpsecPage() {
|
||||
setCertByName(null)
|
||||
setCertOpen(true)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Ошибка создания клиента", {
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -420,8 +416,6 @@ export default function IpsecPage() {
|
||||
setUserOpen(false)
|
||||
setEditing(null)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error("Ошибка обновления", { description: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecClientDto } from "@mmapp/contracts/ipsec"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
@@ -63,11 +64,19 @@ function IpsecUserSheet({
|
||||
onSubmit: (form: IpsecUserFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<IpsecUserFormState>(defaultIpsecUserForm)
|
||||
const lock = useRef(false)
|
||||
const opened = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pending = busy || submitting
|
||||
const set = <K extends keyof IpsecUserFormState>(k: K, v: IpsecUserFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open) { opened.current = false; return }
|
||||
if (opened.current) return
|
||||
opened.current = true
|
||||
queueMicrotask(() => setError(null))
|
||||
if (editing) {
|
||||
const edit = editing
|
||||
queueMicrotask(() => setForm({
|
||||
@@ -89,7 +98,7 @@ function IpsecUserSheet({
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!editing && !form.serverId) return false
|
||||
if (!form.name.trim()) return false
|
||||
if (!editing && form.authMethod === "pre-shared-key" && form.psk.trim().length < 8) return false
|
||||
if (form.authMethod === "pre-shared-key" && (!editing || form.psk.trim()) && form.psk.trim().length < 8) return false
|
||||
if (form.useStaticIp && form.staticIp.trim() && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(form.staticIp.trim())) return false
|
||||
// RouterOS отклоняет export-passphrase короче 8 символов
|
||||
const passphrase = form.passphrase.trim()
|
||||
@@ -97,9 +106,19 @@ function IpsecUserSheet({
|
||||
return true
|
||||
}, [form, editing])
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || busy || lock.current) return
|
||||
lock.current = true
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try { await onSubmit(form) }
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Не удалось сохранить клиента") }
|
||||
finally { lock.current = false; setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<Sheet open={open} onOpenChange={value => { if (!lock.current && !busy) onOpenChange(value) }}>
|
||||
<SheetContent showCloseButton={!pending} side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>{editing ? `Клиент «${editing.name}»` : "Новый клиент IKEv2"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
@@ -110,14 +129,16 @@ function IpsecUserSheet({
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<fieldset disabled={pending} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
{!editing ? (
|
||||
<FormField label="Сервер" required>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
aria-label="Сервер"
|
||||
value={form.serverId}
|
||||
onChange={(e) => set("serverId", e.target.value)}
|
||||
onChange={(e) => setForm(f => ({ ...f, serverId: e.target.value, peerName: "" }))}
|
||||
>
|
||||
<option value="">Выберите сервер…</option>
|
||||
{servers.map((s) => (
|
||||
@@ -132,6 +153,7 @@ function IpsecUserSheet({
|
||||
<FormField label="Peer (сервер)" hint="К какому peer привязать identity">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
aria-label="Peer (сервер)"
|
||||
value={form.peerName}
|
||||
onChange={(e) => set("peerName", e.target.value)}
|
||||
>
|
||||
@@ -149,6 +171,7 @@ function IpsecUserSheet({
|
||||
<FormField label="Имя клиента" required hint={editing ? undefined : "CN сертификата и отображаемое имя"}>
|
||||
<Input
|
||||
placeholder="alice"
|
||||
aria-label="Имя клиента"
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
@@ -187,6 +210,7 @@ function IpsecUserSheet({
|
||||
<Input
|
||||
className="font-mono"
|
||||
type="password"
|
||||
aria-label="Secret (PSK)"
|
||||
value={form.psk}
|
||||
onChange={(e) => set("psk", e.target.value)}
|
||||
/>
|
||||
@@ -196,6 +220,7 @@ function IpsecUserSheet({
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="alice"
|
||||
aria-label="Remote ID"
|
||||
value={form.remoteId}
|
||||
onChange={(e) => set("remoteId", e.target.value)}
|
||||
/>
|
||||
@@ -215,6 +240,7 @@ function IpsecUserSheet({
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="например MySecret123"
|
||||
aria-label="Пароль архива .p12"
|
||||
value={form.passphrase}
|
||||
onChange={(e) => set("passphrase", e.target.value)}
|
||||
/>
|
||||
@@ -233,31 +259,35 @@ function IpsecUserSheet({
|
||||
{" "}Статический IP создаёт персональную identity на peer.
|
||||
</p>
|
||||
</div>
|
||||
<FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} />
|
||||
<Switch aria-label="Статический IP" disabled={pending} checked={form.useStaticIp} onCheckedChange={(v) => set("useStaticIp", v)} />
|
||||
</div>
|
||||
{form.useStaticIp && (
|
||||
<FormField label="IP клиента" hint="Например 10.77.0.10">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder={freeIpHint ?? "10.77.0.10"}
|
||||
aria-label="IP клиента"
|
||||
value={form.staticIp}
|
||||
onChange={(e) => set("staticIp", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
{pending && <p role="status" className="text-sm text-muted-foreground">Ожидание результата сохранения…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={pending} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!canSubmit || busy}
|
||||
onClick={() => void onSubmit(form)}
|
||||
disabled={!canSubmit || pending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{busy ? (editing ? "Сохранение…" : "Создание…") : editing ? "Сохранить" : "Создать клиента"}
|
||||
{pending ? (editing ? "Сохранение…" : "Создание…") : editing ? "Сохранить" : "Создать клиента"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
||||
|
||||
## Текущая точка продолжения
|
||||
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.14 — история, восстановление и инициализация IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.15 — история, восстановление, инициализация и пользователи IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
|
||||
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.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, сохранение формы и защита повторной отправки. Далее — создание/редактирование пользователей IPsec, затем сертификаты и GRE/VXLAN/Firewall/Filters.
|
||||
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 и сохранение формы. Далее — получение и повторный экспорт сертификатов IPsec, затем GRE/VXLAN/Firewall/Filters.
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
@@ -25,12 +25,19 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.14 — история, восстановление и инициализация IPsec |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.15 — история, восстановление, инициализация и пользователи IPsec |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.15 — создание и редактирование пользователей IPsec (2026-09-29)
|
||||
|
||||
- Создание проверяет ipsecUserCreatedSchema, принадлежность клиента серверу, имя и метод аутентификации. Пустой ответ, client=null и чужой сервер не считаются успехом; редактирование требует ok=true. Backend иногда возвращает client=null после применения: UI сообщает отсутствие подтверждения, а не утверждает откат операции.
|
||||
- Ошибка остаётся в форме с введёнными значениями. Синхронный замок защищает повторное нажатие, поля/переключатель и закрытие блокируются до ответа; ожидание объявляется через status. Начальные значения задаются один раз на открытие. При смене сервера сбрасывается peer; короткий новый PSK при редактировании блокирует отправку. Поля получили доступные имена.
|
||||
- Все 7 E2E прошли: три новых сценария пользователей (сертификат, PSK, редактирование) и четыре регрессионных проверки инициализации/таблиц. Проверены задержка, двойное нажатие, 403, повреждённый/неподтверждённый ответ, сохранение ввода и успех. Lint изменённых файлов, TypeScript и diff-check чистые. Build не повторялся, последний — 9.13.
|
||||
- Далее: сертификаты IPsec — openCert/openCertByName/reexportCert, проверка бандла, сохранение контекста и защита поздних ответов/повторов. Получение настоящего сертификата, RouterOS/JWT и транзакционность операций не проверены. Этап 9 открыт. Локальный коммит без push; чужие изменения сохранены.
|
||||
|
||||
## Блок 9.14 — инициализация IPsec (2026-09-29)
|
||||
|
||||
- Успех требует валидной сводки по contracts, совпадения serverId и initialized=true. Пустой ответ, неинициализированный сервер и чужое устройство не закрывают форму и не показывают успех. Backend возвращает сводку после применения; правила сетевой операции не изменялись.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Этап 9: VPN и сетевые операции
|
||||
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.14. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.15. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
|
||||
## Схема и референсы принятого плана
|
||||
|
||||
@@ -97,3 +97,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую
|
||||
## Блок 9.14 — инициализация IPsec (2026-09-29)
|
||||
|
||||
Проверяются схема сводки, serverId и initialized=true. Ошибка остаётся в форме с введёнными значениями; замок блокирует повтор, редактирование и закрытие во время запроса. Исправлен ввод IPv4, добавлены доступные имена. Новый E2E и 3 теста таблиц прошли, lint/TypeScript чистые. Далее — создание/редактирование пользователей IPsec. Реальные устройства и транзакционность сетевой операции не проверены; последний build — 9.13.
|
||||
|
||||
## Блок 9.15 — пользователи IPsec (2026-09-29)
|
||||
|
||||
Создание проверяет схему клиента, сервер, имя и метод; редактирование требует ok=true. Форма сохраняет ввод и ошибку, блокирует повторы/закрытие/поля до ответа; добавлены доступные имена, сброс peer при смене сервера и проверка нового PSK. Все 7 E2E, lint и TypeScript прошли. Далее — загрузка/повторный экспорт сертификатов и контекст запросов. Реальные устройства и выпуск сертификатов не проверены; последний build — 9.13.
|
||||
+10
-3
@@ -1,4 +1,4 @@
|
||||
import { ipsecServerSummaryDtoSchema } from "@mmapp/contracts/ipsec"
|
||||
import { ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema } from "@mmapp/contracts/ipsec"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import type {
|
||||
IpsecCertBundle,
|
||||
@@ -55,10 +55,15 @@ export async function createIpsecUser(
|
||||
baseUrl: string,
|
||||
body: IpsecUserCreateRequest,
|
||||
): Promise<IpsecUserCreated> {
|
||||
return requestJson<IpsecUserCreated>(baseUrl, "/api/ipsec/users", {
|
||||
const response = await requestJson<unknown>(baseUrl, "/api/ipsec/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const result = ipsecUserCreatedSchema.safeParse(response)
|
||||
if (!result.success || result.data.client.serverId !== String(body.serverId) || result.data.client.name !== body.name.trim() || result.data.client.authMethod !== (body.authMethod ?? "certificate")) {
|
||||
throw new Error("Сервер не подтвердил создание клиента")
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function patchIpsecUser(
|
||||
@@ -67,11 +72,13 @@ export async function patchIpsecUser(
|
||||
clientId: string,
|
||||
patch: IpsecUserPatch,
|
||||
): Promise<{ ok: boolean }> {
|
||||
return requestJson<{ ok: boolean }>(
|
||||
const result = await requestJson<{ ok: boolean }>(
|
||||
baseUrl,
|
||||
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}`,
|
||||
{ method: "PATCH", body: JSON.stringify(patch) },
|
||||
)
|
||||
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение клиента")
|
||||
return result
|
||||
}
|
||||
|
||||
export async function deleteIpsecUser(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
for (const mode of ["certificate", "pre-shared-key", "edit"] as const) {
|
||||
test(`IPsec: форма клиента ${mode} сохраняет ввод и требует подтверждения`, async ({ page }) => {
|
||||
const editing = mode === "edit"
|
||||
const authMethod = mode === "certificate" ? "certificate" : "pre-shared-key"
|
||||
const client = { id: "client-1", rosId: "*P/1", serverId: "1", serverName: "Роутер", name: "alice", authMethod, online: false, disabled: false, managed: true }
|
||||
let attempts = 0
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
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 (route.request().method() === (editing ? "PATCH" : "POST") && path.startsWith("/api/ipsec/users")) {
|
||||
attempts++
|
||||
expect(route.request().postDataJSON()).toMatchObject({ name: "bob", staticIp: "10.77.0.10" })
|
||||
if (editing) expect(decodeURIComponent(path)).toBe("/api/ipsec/users/1/*P/1")
|
||||
else expect(route.request().postDataJSON()).toMatchObject({ serverId: "1", authMethod })
|
||||
if (authMethod === "pre-shared-key") expect(route.request().postDataJSON().psk).toBe("test-secret-123")
|
||||
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Клиент запрещён тест" } }) }
|
||||
if (attempts === 2) return route.fulfill({ json: {} })
|
||||
if (attempts === 3) return route.fulfill({ json: editing ? { ok: false } : { client: null } })
|
||||
if (attempts === 4 && !editing) return route.fulfill({ json: { client: { ...client, name: "bob", serverId: "2" } } })
|
||||
return route.fulfill({ json: editing ? { ok: true } : { client: { ...client, name: "bob" } } })
|
||||
}
|
||||
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
|
||||
: path === "/api/ipsec" ? { servers: [], clients: editing ? [client] : [] }
|
||||
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
|
||||
})
|
||||
await page.goto("/ipsec")
|
||||
await page.getByRole("button", { name: editing ? "alice" : "Клиент", exact: true }).click()
|
||||
const sheet = page.getByRole("dialog")
|
||||
if (!editing) {
|
||||
await sheet.getByLabel("Сервер", { exact: true }).selectOption("1")
|
||||
if (mode === "pre-shared-key") await sheet.getByRole("button", { name: "PSK", exact: true }).click()
|
||||
}
|
||||
await sheet.getByLabel("Имя клиента").fill("bob")
|
||||
const submit = sheet.getByRole("button", { name: editing ? "Сохранить" : "Создать клиента", exact: true })
|
||||
if (authMethod === "pre-shared-key") {
|
||||
await sheet.getByLabel("Secret (PSK)").fill("short")
|
||||
await expect(submit).toBeDisabled()
|
||||
await sheet.getByLabel("Secret (PSK)").fill("test-secret-123")
|
||||
}
|
||||
await sheet.getByRole("switch", { name: "Статический IP" }).click()
|
||||
await sheet.getByLabel("IP клиента", { exact: true }).fill("10.77.0.10")
|
||||
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||
await expect.poll(() => attempts).toBe(1)
|
||||
await expect(sheet.getByRole("status")).toContainText("Ожидание результата")
|
||||
await expect(sheet.getByLabel("Имя клиента")).toBeDisabled()
|
||||
await expect(sheet.getByRole("switch")).toBeDisabled()
|
||||
await expect(sheet.getByRole("button", { name: "Отмена" })).toBeDisabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.mouse.click(10, 10)
|
||||
await expect(sheet).toBeVisible()
|
||||
release!()
|
||||
await expect(sheet.getByRole("alert")).toContainText("Клиент запрещён тест")
|
||||
await expect(sheet.getByLabel("Имя клиента")).toHaveValue("bob")
|
||||
await expect(sheet.getByLabel("IP клиента", { exact: true })).toHaveValue("10.77.0.10")
|
||||
for (let attempt = 2; attempt <= (editing ? 3 : 4); attempt++) {
|
||||
await submit.click()
|
||||
await expect.poll(() => attempts).toBe(attempt)
|
||||
await expect(sheet.getByRole("alert")).toContainText("Сервер не подтвердил")
|
||||
}
|
||||
await submit.click()
|
||||
await expect(sheet).toHaveCount(0)
|
||||
expect(attempts).toBe(editing ? 4 : 5)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user