diff --git a/.gitignore b/.gitignore index ea243768..7a060814 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ pnpm-debug.log* # coverage coverage/ +test-results/ +playwright-report/ +test-results/ +playwright-report/ # typescript *.tsbuildinfo diff --git a/app/(main)/servers/page.tsx b/app/(main)/servers/page.tsx index c1bfe752..85b155d3 100644 --- a/app/(main)/servers/page.tsx +++ b/app/(main)/servers/page.tsx @@ -1,6 +1,10 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" +import { serverCreateSchema } from "@mmapp/contracts/servers" +import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations" +import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" +import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog" import { PageHeader } from "@/components/page-header" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" import { DataPageToolbar } from "@/components/data-page-toolbar" @@ -231,35 +235,83 @@ export default function ServersPage() { const [testMsg, setTestMsg] = useState("") const [pollingIds, setPollingIds] = useState>(new Set()) const [pollAllBusy, setPollAllBusy] = useState(false) + const [saving, setSaving] = useState(false) + const [detailsLoading, setDetailsLoading] = useState(false) + const [detailsReady, setDetailsReady] = useState(true) + const [formError, setFormError] = useState("") + const [operationMessage, setOperationMessage] = useState("") + const [operationError, setOperationError] = useState("") + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState("") + const [pollResults, setPollResults] = useState([]) + const [rowBusy, setRowBusy] = useState(false) + const gate = useRef(new OperationGate()) + const formVersion = useRef(0) + const testVersion = useRef(0) + const contextVersion = useRef(0) + const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0 - const set = (k: K, v: FormState[K]) => + const set = (k: K, v: FormState[K]) => { + if (gate.current.has("save") || detailsLoading) return + if (["host", "port", "proto", "verifySsl", "apiPath", "username", "password"].includes(k)) { + testVersion.current++ + setTestState("idle"); setTestMsg("") + } setForm((f) => ({ ...f, [k]: v })) + } + + function closeForm(next: boolean) { + if (gate.current.has("save")) return + if (!next) { formVersion.current++; testVersion.current++ } + setOpen(next) + } // ── load from backend when mode/url changes ─────────────────────────────── useEffect(() => { + const version = ++contextVersion.current + let cancelled = false + const invalidate = () => { + cancelled = true + contextVersion.current++ + formVersion.current++ + testVersion.current++ + } + queueMicrotask(() => { + if (cancelled) return + formVersion.current++; testVersion.current++ + setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([]) + }) if (mode !== "live") { queueMicrotask(() => { + if (cancelled) return setServerList(initialServers) setBackendOk(false) }) - return + return invalidate } listServers(backendUrl) .then(data => { + if (cancelled || version !== contextVersion.current) return setBackendOk(true) setServerList(data.map(toFrontendServer)) }) - .catch(() => { + .catch((error) => { + if (cancelled || version !== contextVersion.current) return + setOperationError(serverOperationError(error)) setBackendOk(false) setServerList([]) }) - // eslint-disable-next-line react-hooks/exhaustive-deps + return invalidate }, [mode, backendUrl]) // ── actions ────────────────────────────────────────────────────────────── function openAdd() { + if (gate.current.has("save")) return + formVersion.current++; testVersion.current++ + setFormError(""); setDetailsLoading(false); setDetailsReady(true) setSheetMode("add"); setEditingId(null) setForm(defaultForm); setTestState("idle"); setTestMsg("") setSheetStep(1) @@ -267,11 +319,15 @@ export default function ServersPage() { } async function openEdit(s: Server) { + if (gate.current.has("save")) return + const version = ++formVersion.current + testVersion.current++ + setFormError(""); setDetailsLoading(isLive); setDetailsReady(!isLive) setSheetMode("edit"); setEditingId(s.id) setForm({ ...defaultForm, name: s.name, type: s.type, site: s.site, country: s.country ?? "RU", - asn: s.asn ?? "", enabled: s.status !== "offline", host: s.host, + asn: s.asn ?? "", enabled: s.enabled, host: s.host, comment: s.comment ?? "", lanSubnet: s.lanSubnet ?? "", wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [], @@ -282,9 +338,11 @@ export default function ServersPage() { if (isLive) { try { const full = await getServer(backendUrl, s.id) + if (version !== formVersion.current) return setForm(prev => ({ ...prev, username: full.username ?? "", + enabled: full.enabled, password: full.password ?? "", port: String(full.port ?? 443), proto: full.useSsl ? "https" : "http", @@ -295,16 +353,23 @@ export default function ServersPage() { ? JSON.parse(JSON.stringify(full.wanUplinks)) : prev.wanUplinks, })) - } catch { - // ignore — user can fill in manually + setDetailsReady(true) + } catch (error) { + if (version === formVersion.current) setFormError(serverOperationError(error)) + } finally { + if (version === formVersion.current) setDetailsLoading(false) } } } async function handleSave() { + if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return + const context = contextVersion.current + setSaving(true); setFormError("") + try { const payload: ServerCreate = { host: form.host, - port: Number(form.port) || (form.proto === "https" ? 443 : 80), + port: Number(form.port), username: form.username, password: form.password, useSsl: form.proto === "https", @@ -320,18 +385,32 @@ export default function ServersPage() { wanUplinks: form.type === "home-router" ? form.wanUplinks : [], } + if (!form.host.trim() || !form.username.trim() || !Number.isInteger(payload.port) || payload.port! > 65535 || payload.port! < 1) { + setFormError("Укажите хост, имя пользователя и порт от 1 до 65535.") + setSheetStep(3) + return + } + const validation = serverCreateSchema.safeParse(payload) + if (!validation.success) { + setFormError("Проверьте параметры сервера: " + validation.error.issues.map(i => i.path.join(".")).join(", ")) + return + } + if (isLive) { try { if (sheetMode === "edit" && editingId) { const updatedPayload: ServerUpdate = payload const updated = await updateServer(backendUrl, editingId, updatedPayload) + if (context !== contextVersion.current) return setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s)) } else { const created = await createServer(backendUrl, payload) + if (context !== contextVersion.current) return setServerList(list => [...list, toFrontendServer(created)]) } } catch (e) { - console.error("Ошибка сохранения:", e) + if (context === contextVersion.current) setFormError(serverOperationError(e)) + return } } else { // offline mode — update local state only @@ -349,7 +428,7 @@ export default function ServersPage() { comment: form.comment, lanSubnet: form.lanSubnet || undefined, wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined, - status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline", + enabled: form.enabled, } : s )) @@ -368,57 +447,80 @@ export default function ServersPage() { } setOpen(false) + formVersion.current++; testVersion.current++ + setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.") + } finally { + gate.current.release("save"); setSaving(false) + } } - function handleToggleStatus(id: string) { - setServerList(list => list.map(s => - s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s - )) + async function handleToggleStatus(id: string) { + const server = serverList.find(s => s.id === id) + if (!server || !gate.current.acquire("rows")) return + const context = contextVersion.current + setRowBusy(true); setOperationError(""); setOperationMessage("") + try { + let enabled = !server.enabled + if (isLive) enabled = (await updateServer(backendUrl, id, { enabled })).enabled + if (context !== contextVersion.current) return + setServerList(list => list.map(s => s.id === id ? { ...s, enabled } : s)) + setOperationMessage(`${server.name}: ${enabled ? "включён" : "отключён"}${isLive ? "." : " (демо)."}`) + } catch (error) { + if (context === contextVersion.current) setOperationError(serverOperationError(error)) + } finally { gate.current.release("rows"); setRowBusy(false) } } async function handleDelete(id: string) { - if (isLive) { - try { - await deleteServer(backendUrl, id) - } catch (e) { - console.error("Ошибка удаления:", e) - } - } - setServerList(list => list.filter(s => s.id !== id)) + if (!gate.current.acquire("rows")) return + const context = contextVersion.current + setDeleting(true); setRowBusy(true); setDeleteError("") + try { + if (isLive) await deleteServer(backendUrl, id) + if (context !== contextVersion.current) return + setServerList(list => list.filter(s => s.id !== id)) + setDeleteTarget(null); setOperationError(""); setOperationMessage("Сервер удалён из списка.") + } catch (error) { + if (context === contextVersion.current) setDeleteError(serverOperationError(error)) + } finally { gate.current.release("rows"); setDeleting(false); setRowBusy(false) } } async function handlePoll(id: string) { - if (!isLive) return - setPollingIds(p => new Set(p).add(id)) - try { - await pollServer(backendUrl, id) - // Reload full list to get updated status/version - const data = await listServers(backendUrl) - setServerList(data.map(toFrontendServer)) - } catch (e) { - console.error("Poll error:", e) - } finally { - setPollingIds(p => { const n = new Set(p); n.delete(id); return n }) - } + await runPoll([id]) } async function handlePollAll() { - if (!isLive) return - setPollAllBusy(true) + await runPoll(serverList.map(s => s.id)) + } + + async function runPoll(ids: string[]) { + if (!isLive || !gate.current.acquire("rows")) return + const context = contextVersion.current + setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([]) try { - const ids = serverList.map(s => s.id) - await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {}))) - const data = await listServers(backendUrl) - setServerList(data.map(toFrontendServer)) + const results = await pollServers(ids, async id => { + if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.") + await pollServer(backendUrl, id) + }) + if (context !== contextVersion.current) return + setPollResults(results) + try { + const data = await listServers(backendUrl) + if (context === contextVersion.current) setServerList(data.map(toFrontendServer)) + } catch (error) { + if (context === contextVersion.current) setOperationError("Опрос завершён, но список не обновлён. " + serverOperationError(error)) + } } finally { - setPollAllBusy(false) + gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set()) } } async function handleTest() { + if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return + const version = ++testVersion.current if (!form.host || !form.username) { setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return } + gate.current.acquire("test") setTestState("testing"); setTestMsg("") try { const data = await testServerConnection(backendUrl, { @@ -430,14 +532,18 @@ export default function ServersPage() { username: form.username, password: form.password, }) + if (version !== testVersion.current) return if (data.success) { setTestState("ok"); setTestMsg(data.message) } else { setTestState("error"); setTestMsg(data.message) } - } catch { + } catch (error) { + if (version !== testVersion.current) return setTestState("error") - setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`) + setTestMsg(serverOperationError(error)) + } finally { + gate.current.release("test") } } @@ -478,11 +584,11 @@ export default function ServersPage() { crumbs={[{ label: "Управление" }, { label: "Серверы" }]} actions={ <> - - + } /> @@ -490,6 +596,18 @@ export default function ServersPage() {
+ {operationError && Действие не завершено{operationError}} + {operationMessage && {operationMessage}} + {pollResults.length > 0 && ( + r.error) ? "warning" : "success"} role="status"> + Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length} + +
    {pollResults.map(r =>
  • {serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}
  • )}
+ {pollResults.some(r => r.error) && } +
+
+ )} + { setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }} onToggleStatus={handleToggleStatus} /> @@ -558,13 +677,19 @@ export default function ServersPage() {
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */} - - + + {sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"} MikroTik RouterOS · Web API (REST) + {detailsLoading &&

Загрузка параметров сервера…

} + {formError &&
{formError} + {!detailsReady && !detailsLoading && editingId && } +
} + +
@@ -776,22 +901,36 @@ export default function ServersPage() { +
- }>Отмена + }>Отмена {sheetStep > 1 && ( - + )} {sheetStep < 4 ? ( - + ) : ( - )}
+ { if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}> + + + Удалить сервер? + Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления. + + {deleteError &&

{deleteError}

} + + + + +
+
) } diff --git a/components/data-grids/servers-data-grid.tsx b/components/data-grids/servers-data-grid.tsx index e29f9570..575e9fb6 100644 --- a/components/data-grids/servers-data-grid.tsx +++ b/components/data-grids/servers-data-grid.tsx @@ -86,6 +86,7 @@ function RosBadge({ os }: { os: string }) { } interface ServerRowActionsProps { + busy: boolean server: Server isLive: boolean isPolling: boolean @@ -96,6 +97,7 @@ interface ServerRowActionsProps { } function ServerRowActions({ + busy, server, isLive, isPolling, @@ -114,6 +116,7 @@ function ServerRowActions({ onToggleStatus(server.id)}> - {server.status === "offline" ? "Включить" : "Отключить"} + {server.enabled ? "Отключить" : "Включить"} onDelete(server.id)}> @@ -162,6 +165,7 @@ function ServerRowActions({ } interface ServersDataGridProps { + busy?: boolean servers: Server[] isLive: boolean pollingIds: Set @@ -172,6 +176,7 @@ interface ServersDataGridProps { } function ServersDataGrid({ + busy = false, servers, isLive, pollingIds, @@ -326,7 +331,7 @@ function ServersDataGrid({ id: "status", accessorKey: "status", header: ({ column }) => , - cell: ({ row }) => , + cell: ({ row }) =>
{!row.original.enabled && Отключён}
, meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { @@ -336,6 +341,7 @@ function ServersDataGrid({ const s = row.original return ( () + + acquire(key: string): boolean { + if (this.keys.has(key)) return false + this.keys.add(key) + return true + } + + release(key: string) { this.keys.delete(key) } + has(key: string) { return this.keys.has(key) } +} + +export type PollResult = { id: string; error: string | null } + +export function serverOperationError(error: unknown): string { + if (error && typeof error === "object" && "status" in error && error.status === 403) { + return "Недостаточно прав для этого действия." + } + if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) { + return "Время ожидания истекло. Результат операции неизвестен — обновите данные перед повтором." + } + if (error instanceof TypeError) return "Нет соединения с сервером. Проверьте подключение и обновите данные." + return error instanceof Error ? error.message : "Не удалось выполнить действие. Попробуйте ещё раз." +} + +/** Bounded polling, preserving one outcome for every requested server. */ +export async function pollServers( + ids: string[], + poll: (id: string) => Promise, +): Promise { + const uniqueIds = [...new Set(ids)] + const results: PollResult[] = new Array(uniqueIds.length) + let next = 0 + await Promise.all(Array.from({ length: Math.min(4, uniqueIds.length) }, async () => { + while (next < uniqueIds.length) { + const index = next++ + const id = uniqueIds[index] + try { + await poll(id) + results[index] = { id, error: null } + } catch (error) { + results[index] = { id, error: serverOperationError(error) } + } + } + })) + return results +} diff --git a/package-lock.json b/package-lock.json index e9eb6726..47ea0582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -3686,6 +3687,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", @@ -11529,6 +11546,35 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 24f9c669..ebc2422e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "postinstall": "npm run build -w @mmapp/contracts", "prepare": "node .ci/scripts/ensure-git-hooks.mjs", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test:servers": "playwright test tests/e2e/servers.spec.ts" }, "dependencies": { "@base-ui/react": "^1.4.1", @@ -40,6 +41,7 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..77d30b53 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 45_000, + use: { + baseURL: "http://127.0.0.1:3100", + browserName: "chromium", + channel: "msedge", + viewport: { width: 1440, height: 1000 }, + trace: "retain-on-failure", + }, + webServer: { + command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3100", + url: "http://127.0.0.1:3100", + reuseExistingServer: false, + timeout: 120_000, + env: { + NEXT_PUBLIC_BACKEND_URL: "same-origin", + NEXT_PUBLIC_DEFAULT_DATA_SOURCE: "live", + NEXT_PUBLIC_ALLOW_MOCK_DATA: "true", + NEXT_PUBLIC_AUTH_ENABLED: "false", + }, + }, +}) diff --git a/shared/api/servers.ts b/shared/api/servers.ts index 6ee0a7aa..c7130aba 100644 --- a/shared/api/servers.ts +++ b/shared/api/servers.ts @@ -7,7 +7,14 @@ import { type ServerUpdate, type TestConnectionRequest, } from "@mmapp/contracts/servers" -import { requestJson } from "@/shared/api/http-client" +import { requestJson as httpRequestJson } from "@/shared/api/http-client" + +function requestJson(baseUrl: string, path: string, init?: RequestInit): Promise { + return httpRequestJson(baseUrl, path, { + ...init, + signal: AbortSignal.timeout(path.endsWith("/poll") ? 60_000 : 30_000), + }) +} type TestConnectionResponse = { success: boolean diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts new file mode 100644 index 00000000..bd2c3d4a --- /dev/null +++ b/tests/e2e/servers.spec.ts @@ -0,0 +1,265 @@ +import { test, expect, type Page, type Route } from "@playwright/test" +import { serverReadSchema, type ServerRead } from "../../packages/contracts/src/servers" + +function server(id: number): ServerRead { + return serverReadSchema.parse({ + id, name: `Проверка ${id}`, host: `192.0.2.${id}`, port: 443, + username: "test-user", password: "fixture-only", useSsl: true, verifySsl: true, + type: "exit-node", site: "TEST", country: "RU", asn: "", comment: "", + enabled: true, lanSubnet: "", wanUplinks: [], + createdAt: "2026-09-13T00:00:00Z", updatedAt: "2026-09-13T00:00:00Z", + status: "offline", latency: null, os: "7.20", model: "Test router", + uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null, + identityName: null, sessions: 0, polledAt: null, + }) +} + +async function setup(page: Page, intercept?: (route: Route, path: string) => Promise, count = 2) { + const rows = Array.from({ length: count }, (_, i) => server(i + 1)) + 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 (await intercept?.(route, path)) return + const method = route.request().method() + let body: unknown = {} + if (path === "/api/auth/config") body = { required: false } + else if (path === "/api/servers") { + if (method === "POST") { + const added = { ...server(rows.length + 1), ...route.request().postDataJSON() } + rows.push(added); body = added + } else body = rows + } else if (/^\/api\/servers\/\d+$/.test(path)) { + const index = rows.findIndex(s => s.id === Number(path.split("/").pop())) + if (method === "DELETE") { rows.splice(index, 1); await route.fulfill({ status: 204 }); return } + if (method === "PUT") rows[index] = { ...rows[index], ...route.request().postDataJSON() } + body = rows[index] + } + await route.fulfill({ json: body }) + }) + await page.goto("/servers") + await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible() +} + +async function edit(page: Page, id = 1) { + await page.getByRole("button", { name: `Действия: Проверка ${id}`, exact: true }).click() + await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click() +} + +async function finalStep(page: Page) { + for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Далее", exact: true }).click() +} + +for (const status of [400, 403, 500]) { + test(`ошибка сохранения ${status} сохраняет форму; повтор завершается успешно`, async ({ page }) => { + let fail = true + await setup(page, async (route, path) => { + if (path === "/api/servers/1" && route.request().method() === "PUT" && fail) { + await route.fulfill({ status, json: { error: "Сохранение отклонено" } }); return true + } + return false + }) + await edit(page) + await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled() + await finalStep(page) + await page.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(page.getByRole("alert")).toContainText(status === 403 ? "Недостаточно прав" : "Сохранение отклонено") + await expect(page.getByRole("dialog")).toBeVisible() + fail = false + await page.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(page.getByRole("dialog")).toBeHidden() + await expect(page.getByText("Сервер сохранён.", { exact: true })).toBeVisible() + }) +} + +test("двойное сохранение отправляет один запрос и блокирует форму", async ({ page }) => { + let requests = 0 + let finish!: () => void + const pending = new Promise(resolve => { finish = resolve }) + await setup(page, async (route, path) => { + if (path === "/api/servers/1" && route.request().method() === "PUT") { + requests++; await pending + await route.fulfill({ json: server(1) }); return true + } + return false + }) + await edit(page); await finalStep(page) + await page.getByRole("button", { name: "Сохранить", exact: true }).evaluate((button: HTMLButtonElement) => { button.click(); button.click() }) + await expect(page.getByRole("button", { name: "Сохранение…", exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled() + await expect.poll(() => requests).toBe(1) + finish() + await expect(page.getByRole("dialog")).toBeHidden() + expect(requests).toBe(1) +}) + +test("ошибка удаления оставляет строку и подтверждение; повтор удаляет", async ({ page }) => { + let fail = true + await setup(page, async (route, path) => { + if (path === "/api/servers/1" && route.request().method() === "DELETE" && fail) { + await route.fulfill({ status: 500, json: { error: "Удаление отклонено" } }); return true + } + return false + }) + await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click() + await page.getByRole("menuitem", { name: "Удалить", exact: true }).click() + await expect(page.getByRole("alertdialog")).toContainText("Проверка 1") + await page.getByRole("button", { name: "Удалить", exact: true }).click() + await expect(page.getByRole("alertdialog")).toContainText("Удаление отклонено") + await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible() + fail = false + await page.getByRole("button", { name: "Удалить", exact: true }).click() + await expect(page.getByRole("alertdialog")).toBeHidden() + await expect(page.getByText("Проверка 1", { exact: true })).toHaveCount(0) +}) + +test("отключение сохраняет offline-статус и отправляет enabled через API", async ({ page }) => { + let payload: unknown + await setup(page, async (route, path) => { + if (path === "/api/servers/1" && route.request().method() === "PUT") payload = route.request().postDataJSON() + return false + }) + await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click() + await page.getByRole("menuitem", { name: "Отключить", exact: true }).click() + await expect(page.getByText("Проверка 1: отключён.", { exact: true })).toBeVisible() + expect(payload).toEqual({ enabled: false }) + await expect(page.getByText("Оффлайн", { exact: true })).toHaveCount(2) +}) + +test("ошибка загрузки деталей блокирует сохранение и позволяет повтор", async ({ page }) => { + let fail = true + await setup(page, async (route, path) => { + if (path === "/api/servers/1" && route.request().method() === "GET" && fail) { + await route.fulfill({ status: 403, json: { error: "Forbidden" } }); return true + } + return false + }) + await edit(page) + await expect(page.getByRole("alert")).toContainText("Недостаточно прав") + await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeDisabled() + fail = false + await page.getByRole("button", { name: "Повторить загрузку", exact: true }).click() + await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled() +}) + +test("поздний ответ первого сервера не перезаписывает второй", async ({ page }) => { + let finish!: () => void + const pending = new Promise(resolve => { finish = resolve }) + await setup(page, async (route, path) => { + if (path === "/api/servers/1" && route.request().method() === "GET") { + await pending; await route.fulfill({ json: { ...server(1), username: "wrong-user" } }); return true + } + return false + }) + await edit(page) + await expect(page.getByText("Загрузка параметров сервера…")).toBeVisible() + await page.getByRole("button", { name: "Отмена", exact: true }).click() + await edit(page, 2) + await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled() + finish() + await page.getByRole("button", { name: "Далее", exact: true }).click() + await page.getByRole("button", { name: "Далее", exact: true }).click() + await expect(page.getByPlaceholder("api-user", { exact: true })).toHaveValue("test-user") +}) + +test("массовый опрос ограничен четырьмя запросами и повторяет только ошибки", async ({ page }) => { + let active = 0, maximum = 0 + const calls: string[] = [] + let fail = true + await setup(page, async (route, path) => { + if (path.endsWith("/poll")) { + calls.push(path); maximum = Math.max(maximum, ++active) + await new Promise(resolve => setTimeout(resolve, 100)) + active-- + await route.fulfill({ status: fail && path === "/api/servers/2/poll" ? 500 : 200, json: { error: "Опрос недоступен" } }) + return true + } + return false + }, 6) + await page.getByRole("button", { name: "Проверить все", exact: true }).click() + await expect(page.getByText("Опрос: успешно 5 из 6", { exact: true })).toBeVisible() + expect(maximum).toBeLessThanOrEqual(4) + expect(calls).toHaveLength(6) + fail = false + await page.getByRole("button", { name: "Повторить неуспешные", exact: true }).click() + await expect(page.getByText("Опрос: успешно 1 из 1", { exact: true })).toBeVisible() + expect(calls.slice(6)).toEqual(["/api/servers/2/poll"]) +}) + +test("таймаут сохранения сохраняет форму и показывает неопределённый результат", async ({ page }) => { + // Speed up only the browser's server-operation deadline, keeping real fetch/AbortSignal. + await page.addInitScript(() => { + const timeout = AbortSignal.timeout.bind(AbortSignal) + AbortSignal.timeout = (ms: number) => timeout(ms === 30_000 ? 500 : ms) + }) + await setup(page, async (route, path) => { + if (path === "/api/servers/1" && route.request().method() === "PUT") { + await new Promise(resolve => setTimeout(resolve, 1500)) + await route.fulfill({ json: server(1) }).catch(() => {}) + return true + } + return false + }) + await edit(page); await finalStep(page) + await page.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(page.getByRole("alert")).toContainText("Результат операции неизвестен") + await expect(page.getByRole("button", { name: "Сохранить", exact: true })).toBeEnabled() +}) + +test("создание сервера проходит через API и добавляет подтверждённую запись", async ({ page }) => { + await setup(page) + await page.getByRole("button", { name: "Добавить сервер", exact: true }).click() + await page.getByRole("button", { name: "Далее", exact: true }).click() + await page.getByRole("button", { name: "Далее", exact: true }).click() + await page.getByPlaceholder("203.0.113.1", { exact: true }).fill("192.0.2.99") + await page.getByPlaceholder("api-user", { exact: true }).fill("fixture-user") + await page.getByRole("button", { name: "Далее", exact: true }).click() + await page.getByRole("dialog").getByRole("button", { name: "Добавить сервер", exact: true }).click() + await expect(page.getByRole("dialog")).toBeHidden() + await expect(page.getByRole("button", { name: "Действия: 192.0.2.99", exact: true })).toHaveCount(1) +}) + +test("успешный опрос и ошибка обновления списка имеют раздельные результаты", async ({ page }) => { + let polled = false + await setup(page, async (route, path) => { + if (path.endsWith("/poll")) { polled = true; await route.fulfill({ json: {} }); return true } + if (path === "/api/servers" && polled) { + await route.fulfill({ status: 500, json: { error: "Список недоступен" } }); return true + } + return false + }) + await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click() + await page.getByRole("menuitem", { name: "Опросить", exact: true }).click() + await expect(page.getByText("Опрос: успешно 1 из 1", { exact: true })).toBeVisible() + await expect(page.getByRole("alert").filter({ hasText: "Действие не завершено" })).toContainText("Опрос завершён, но список не обновлён") + await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible() +}) + +test("ошибка отключения не меняет enabled", async ({ page }) => { + await setup(page, async (route, path) => { + if (path === "/api/servers/1" && route.request().method() === "PUT") { + await route.fulfill({ status: 403, json: { error: "Forbidden" } }); return true + } + return false + }) + await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click() + await page.getByRole("menuitem", { name: "Отключить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Действие не завершено" })).toContainText("Недостаточно прав") + await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click() + await expect(page.getByRole("menuitem", { name: "Отключить", exact: true })).toBeVisible() +}) + +test("изменение подключения сбрасывает результат его проверки", async ({ page }) => { + await setup(page, async (route, path) => { + if (path.endsWith("/test-connection")) { + await route.fulfill({ json: { success: true, message: "Проверено тестовым API" } }); return true + } + return false + }) + await edit(page) + await page.getByRole("button", { name: "Далее", exact: true }).click() + await page.getByRole("button", { name: "Далее", exact: true }).click() + await page.getByRole("button", { name: "Проверить подключение", exact: true }).click() + await expect(page.getByText("Проверено тестовым API", { exact: true })).toBeVisible() + await page.getByPlaceholder("api-user", { exact: true }).fill("changed-user") + await expect(page.getByText("Проверено тестовым API", { exact: true })).toHaveCount(0) +})