fix(ui): различать результат и отмену диагностики

This commit is contained in:
Denozordec committed 2026-10-01 04:07:08 +07:00
1 parent 8bda85129f
commit 6fa8e5bfd1
9 files changed
+132 -20

No files matched your search

+42 -18
View File
@@ -20,6 +20,7 @@ import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data" import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { readProbeOutput } from "@/lib/probe-response"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { import {
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon, PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
@@ -33,7 +34,7 @@ import { requestJson } from "@/shared/api/http-client"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu" type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
type RunStatus = "running" | "done" | "error" type RunStatus = "running" | "done" | "error" | "cancelled"
type TraceProto = "icmp" | "udp" | "tcp" type TraceProto = "icmp" | "udp" | "tcp"
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR" type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
@@ -576,6 +577,12 @@ function ScheduleTab({
// ─── page ───────────────────────────────────────────────────────────────────── // ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() { export default function ProbesPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status">Загрузка настроек диагностики…</p>
return <ProbesContent key={JSON.stringify([mode, backendUrl])} />
}
function ProbesContent() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
@@ -716,6 +723,13 @@ export default function ProbesPage() {
/** Живой POST /probes/run — отмена через fetch AbortSignal */ /** Живой POST /probes/run — отмена через fetch AbortSignal */
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null) const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
const runLock = useRef(false)
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false; liveProbeRunRef.current?.ctrl.abort() }
}, [])
const bwTunnels = tunnelsForSrc const bwTunnels = tunnelsForSrc
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId]) const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId]) const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
@@ -748,15 +762,18 @@ export default function ProbesPage() {
// ── run test ── // ── run test ──
const runTest = useCallback(async () => { const runTest = useCallback(async () => {
const srv = allServers.find(s => s.id === srcId) const srv = allServers.find(s => s.id === srcId)
if (!srv) return if (!srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
const id = String(nextId.current++) const id = String(nextId.current++)
const numericServerId = Number.parseInt(srcId, 10) const numericServerId = Number.parseInt(srcId, 10)
if (isLive) { if (isLive) {
if (!Number.isFinite(numericServerId)) return if (!Number.isFinite(numericServerId)) return
runLock.current = true
setRunBusy(true) setRunBusy(true)
const abortCtrl = new AbortController() const abortCtrl = new AbortController()
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl } liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
let timedOut = false
const timeout = setTimeout(() => { timedOut = true; abortCtrl.abort() }, 60_000)
setTests(p => [{ setTests(p => [{
id, id,
tool, tool,
@@ -793,32 +810,34 @@ export default function ProbesPage() {
bwProto, bwProto,
bwDuration, bwDuration,
} }
const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, { const res = await apiFetch<unknown>(`/api/servers/${numericServerId}/probes/run`, {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
signal: abortCtrl.signal, signal: abortCtrl.signal,
}) })
const lines = parseProbeOutput(res.output ?? "") if (!alive.current || abortCtrl.signal.aborted) return
const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:") const output = readProbeOutput(res)
const lines = parseProbeOutput(output || "Запрос выполнен: API вернул пустой вывод.")
setTests(p => p.map(t => (t.id === id setTests(p => p.map(t => (t.id === id
? { ? {
...t, ...t,
lines, lines,
totalLines: lines.length, totalLines: lines.length,
status: looksErr ? "error" : "done", status: "done",
} }
: t))) : t)))
} catch (e) { } catch (e) {
const aborted = if (!alive.current) return
const aborted = abortCtrl.signal.aborted ||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|| (e instanceof Error && e.name === "AbortError") || (e instanceof Error && e.name === "AbortError")
if (aborted) { if (aborted) {
setTests(p => p.map(t => (t.id === id setTests(p => p.map(t => (t.id === id
? { ? {
...t, ...t,
lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }], lines: [{ text: timedOut ? "Время ожидания истекло. Результат на устройстве не подтверждён; повтор выполняется вручную." : "Ожидание отменено. Остановка на устройстве не подтверждена.", kind: timedOut ? "err" : "dim" }],
totalLines: 1, totalLines: 1,
status: "done", status: timedOut ? "error" : "cancelled",
} }
: t))) : t)))
} else { } else {
@@ -833,8 +852,10 @@ export default function ProbesPage() {
: t))) : t)))
} }
} finally { } finally {
clearTimeout(timeout)
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
setRunBusy(false) runLock.current = false
if (alive.current) setRunBusy(false)
} }
return return
} }
@@ -867,6 +888,7 @@ export default function ProbesPage() {
setTab("history") setTab("history")
}, [ }, [
isLive, isLive,
liveLoad,
allServers, allServers,
srcId, srcId,
tool, tool,
@@ -887,12 +909,15 @@ export default function ProbesPage() {
]) ])
const stopTest = (id: string) => { const stopTest = (id: string) => {
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort() if (liveProbeRunRef.current?.testId === id) { liveProbeRunRef.current.ctrl.abort(); return }
setTests(p => p.map(t => (t.id === id && t.status === "running" setTests(p => p.map(t => (t.id === id && t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length } ? { ...t, status: "cancelled" as const, totalLines: t.lines.length }
: t))) : t)))
} }
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id)) const clearTest = (id: string) => {
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
setTests(p => p.filter(t => t.id !== id))
}
const running = tests.filter(t => t.status === "running") const running = tests.filter(t => t.status === "running")
@@ -923,10 +948,7 @@ export default function ProbesPage() {
<ServerRailMobileButton /> <ServerRailMobileButton />
{running.length > 0 {running.length > 0
? <Button variant="outline" size="sm" onClick={() => { ? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort() running.forEach(test => stopTest(test.id))
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}> }}>
<SquareIcon className="size-4" />Остановить все <SquareIcon className="size-4" />Остановить все
</Button> </Button>
@@ -984,6 +1006,7 @@ export default function ProbesPage() {
</OptionLabel> </OptionLabel>
<Input value={target} onChange={e => setTarget(e.target.value)} <Input value={target} onChange={e => setTarget(e.target.value)}
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"} placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
aria-label={tool === "dns" ? "Домен" : "Цель диагностики"}
className="font-mono text-sm" className="font-mono text-sm"
onKeyDown={e => e.key === "Enter" && runTest()} onKeyDown={e => e.key === "Enter" && runTest()}
/> />
@@ -1066,7 +1089,7 @@ export default function ProbesPage() {
<Button <Button
onClick={() => void runTest()} onClick={() => void runTest()}
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)} disabled={runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
className="h-8 shrink-0 gap-1.5 self-end" className="h-8 shrink-0 gap-1.5 self-end"
> >
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />} {runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
@@ -1155,6 +1178,7 @@ export default function ProbesPage() {
{test.status === "done" && test.source === "live" && ( {test.status === "done" && test.source === "live" && (
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span> <span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
)} )}
{test.status === "cancelled" && <span role="status" className="text-[11px] text-muted-foreground">ожидание отменено</span>}
{test.status === "error" && ( {test.status === "error" && (
<span className="text-[11px] text-destructive">ошибка</span> <span className="text-[11px] text-destructive">ошибка</span>
)} )}
+9 -1
View File
@@ -26,7 +26,7 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.57; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | | 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.58; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 10.58 — достоверный результат диагностики GRE (2026-10-01)
- /probes проверяет строковый output и legacy error; неверный контракт не превращается в пустой успех. Пустой подтверждённый вывод подписан, цель/параметры сохраняются после отказа.
- Немедленный single-flight lock закрывает повторный Enter; disabled устройство и неподтверждённый каталог не запускают запрос. Стоп/Остановить все отменяют ожидание со статусом cancelled, без заявления об остановке RouterOS. Таймаут 60 с показывает неизвестность результата и требует ручного повтора.
- Cleanup отменяет запрос; ключ режима/backend исключает перенос прежней истории/вывода. Удаление выполняющегося теста отменяет ожидание. Существующие Frame/состав инструментов/сетевые правила сохранены.
- 7 основных и 4 source E2E, 2 unit, TypeScript и production frontend прошли. Новые helper/test файлы проходят lint. Страница сохраняет исходные 8 set-state-in-effect ошибок и 2 предупреждения: сравнение ESLint HEAD/current подтвердило одинаковые counts; это не чистый lint страницы.
- Далее 10.59: каталог серверов/ошибка/повтор и неизвестный status; затем URL-контекст, права, GRE каталог, backend probes и адаптивная приёмка. Расписание отдельно в 11Б. Реальный RouterOS не проверен.
## Блок 10.57 — локальная приёмка терминала (2026-10-01) ## Блок 10.57 — локальная приёмка терминала (2026-10-01)
- Терминал принят локально: 20 основных E2E (17 данных и 3 адаптивных), 8 source E2E, TypeScript, lint и production frontend build прошли. Проверены timeout/поздний ответ, пустой/disabled каталог, неверный URL, сохранение команды, смена режима/backend и 1200 строк вывода. - Терминал принят локально: 20 основных E2E (17 данных и 3 адаптивных), 8 source E2E, TypeScript, lint и production frontend build прошли. Проверены timeout/поздний ответ, пустой/disabled каталог, неверный URL, сохранение команды, смена режима/backend и 1200 строк вывода.
+9
View File
@@ -338,3 +338,12 @@ Shared контракт и backend observed исходные значения/д
- Границы: HTTP fixtures/DI доказывают обработку UI/API, не реальный RouterOS/БД/портал. REST терминал не является SSH-сессией; clear локальный, отмена/timeout не подтверждают остановку на устройстве. - Границы: HTTP fixtures/DI доказывают обработку UI/API, не реальный RouterOS/БД/портал. REST терминал не является SSH-сессией; clear локальный, отмена/timeout не подтверждают остановку на устройстве.
- Этап 10 остаётся в работе: обнаружен отдельный /probes (Диагностика GRE), который ещё требует приёмки. Далее 10.58: строгий результат, single-flight и отмена запросов диагностики; каталоги/URL/права и backend — отдельными блоками. Расписание относится к 11Б. - Этап 10 остаётся в работе: обнаружен отдельный /probes (Диагностика GRE), который ещё требует приёмки. Далее 10.58: строгий результат, single-flight и отмена запросов диагностики; каталоги/URL/права и backend — отдельными блоками. Расписание относится к 11Б.
## Блок 10.58 — достоверный результат диагностики GRE (2026-10-01)
- /probes проверяет строковый output и legacy error; неверный контракт не превращается в пустой успех. Пустой подтверждённый вывод подписан, цель/параметры сохраняются после отказа.
- Немедленный single-flight lock закрывает повторный Enter; disabled устройство и неподтверждённый каталог не запускают запрос. Стоп/Остановить все отменяют ожидание со статусом cancelled, без заявления об остановке RouterOS. Таймаут 60 с показывает неизвестность результата и требует ручного повтора.
- Cleanup отменяет запрос; ключ режима/backend исключает перенос прежней истории/вывода. Удаление выполняющегося теста отменяет ожидание. Существующие Frame/состав инструментов/сетевые правила сохранены.
- 7 основных и 4 source E2E, 2 unit, TypeScript и production frontend прошли. Новые helper/test файлы проходят lint. Страница сохраняет исходные 8 set-state-in-effect ошибок и 2 предупреждения: сравнение ESLint HEAD/current подтвердило одинаковые counts; это не чистый lint страницы.
- Далее 10.59: каталог серверов/ошибка/повтор и неизвестный status; затем URL-контекст, права, GRE каталог, backend probes и адаптивная приёмка. Расписание отдельно в 11Б. Реальный RouterOS не проверен.
+5
View File
@@ -0,0 +1,5 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { readProbeOutput } from "./probe-response"
test("Диагностика требует подтверждённый string output",()=>{for(const raw of [null,{},[],{output:null},{output:1},{output:"ok",error:"fail"}])assert.throws(()=>readProbeOutput(raw));assert.equal(readProbeOutput({output:""}),"");assert.equal(readProbeOutput({output:"PING"}),"PING")})
test("Диагностика отклоняет legacy error и oversized output",()=>{assert.throws(()=>readProbeOutput({output:" error: отказ"}),/отказ/);assert.throws(()=>readProbeOutput({output:"x".repeat(1024*1024+1)}))})
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod"
const response = z.object({ output: z.string().max(1024 * 1024) }).strict()
export function readProbeOutput(raw: unknown): string {
const result = response.safeParse(raw)
if (!result.success) throw new Error("Некорректный ответ API: результат диагностики не подтверждён.")
if (/^error:/i.test(result.data.output.trimStart())) throw new Error(result.data.output.trim())
return result.data.output
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
testIgnore: ["**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], testIgnore: ["**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server=base.webServer
if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"probes-context.spec.ts",
webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}},
})
+15
View File
@@ -0,0 +1,15 @@
import { test, expect } from "@playwright/test"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
for(const change of ["backend","mode"])for(const fails of [false,true])test("Диагностика: поздний запрос/"+change+"/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}))
await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[{id:1,name:u.port==="8101"?"Новый источник":"Прежний источник",host:"192.0.2.1",country:"RU",enabled:true,status:"online"}]:p.endsWith("/ros-src-address")?{ipv4:"192.0.2.1"}:p.endsWith("/probes/run")?{output:"НОВЫЙ РЕЗУЛЬТАТ"}:{tunnels:[]}})})
await page.goto("/probes");await expect(page.getByRole("button",{name:"Запустить",exact:true})).toBeEnabled()
await page.route("**/api/servers/1/probes/run",async r=>{if(new URL(r.request().url()).port!=="8000")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:{output:"СТАРЫЙ РЕЗУЛЬТАТ"}})}catch{}finally{completed=true}})
await page.getByRole("button",{name:"Запустить",exact:true}).click();await expect.poll(()=>started).toBe(true);await page.getByRole("link",{name:/^Настройки/}).click()
if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click()
await page.goto("/probes");await expect(page.getByRole("button",{name:"Запустить",exact:true})).toBeEnabled();release();await expect.poll(()=>completed).toBe(true)
await page.getByRole("button",{name:"Запустить",exact:true}).click()
if(change==="backend")await expect(page.getByText("НОВЫЙ РЕЗУЛЬТАТ",{exact:true})).toBeVisible()
await expect(page.getByText("СТАРЫЙ РЕЗУЛЬТАТ",{exact:true})).toHaveCount(0);await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0)
})
+35
View File
@@ -0,0 +1,35 @@
import { test, expect, type Page } from "@playwright/test"
const devices=[{id:1,name:"Первый",host:"192.0.2.1",site:"Тест",country:"RU",asn:"AS64500",type:"vps",enabled:true,status:"online",latency:null}]
async function routes(page:Page){
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?devices:p==="/api/auth/config"?{required:false}:p.endsWith("/ros-src-address")?{ipv4:"192.0.2.1"}:{tunnels:[]}})})
}
test.beforeEach(async({page})=>routes(page))
const run=(page:Page)=>page.getByRole("button",{name:"Запустить",exact:true})
for(const [name,data,status] of [["malformed",{},200],["legacy",{output:" error: RouterOS недоступен"},200],["API",{error:"Нет доступа к диагностике"},403]] as const)test("Диагностика: "+name+" сохраняет цель и допускает повтор",async({page})=>{
let fail=true;await page.route("**/api/servers/1/probes/run",r=>r.fulfill(fail?{json:data,status}:{json:{output:"PING подтверждён"}}))
await page.goto("/probes");await expect(run(page)).toBeEnabled();const target=page.getByRole("textbox",{name:"Цель диагностики"});await target.fill("198.51.100.9");await run(page).click()
await expect(page.getByText("ошибка",{exact:true})).toBeVisible();await expect(target).toHaveValue("198.51.100.9");await expect(run(page)).toBeEnabled()
fail=false;await run(page).click();await expect(page.getByText("PING подтверждён",{exact:true})).toBeVisible()
})
test("Диагностика: пустой output явно подтверждён",async({page})=>{
await page.route("**/api/servers/1/probes/run",r=>r.fulfill({json:{output:""}}));await page.goto("/probes");await run(page).click();await expect(page.getByText("Запрос выполнен: API вернул пустой вывод.",{exact:true})).toBeVisible()
})
test("Диагностика: повторный Enter, Стоп и поздний успех",async({page})=>{
let calls=0,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/api/servers/1/probes/run",async r=>{calls++;await gate;await r.fulfill({json:{output:"ЗАПОЗДАЛЫЙ УСПЕХ"}}).catch(()=>{})})
await page.goto("/probes");const target=page.getByRole("textbox",{name:"Цель диагностики"});await target.press("Enter");await target.press("Enter");await expect.poll(()=>calls).toBe(1);await expect(run(page)).toBeDisabled()
await page.getByRole("button",{name:"Стоп",exact:true}).click();await expect(page.getByText("ожидание отменено",{exact:true})).toBeVisible();await expect(run(page)).toBeEnabled();release();await expect(page.getByText("ЗАПОЗДАЛЫЙ УСПЕХ",{exact:true})).toHaveCount(0)
await expect(page.getByText("Ожидание отменено. Остановка на устройстве не подтверждена.",{exact:true})).toBeVisible()
})
test("Диагностика: таймаут сохраняет ввод, без автоматического повтора",async({page})=>{
await page.clock.install();let calls=0,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/api/servers/1/probes/run",async r=>{calls++;await gate;await r.fulfill({json:{output:"ПОЗДНО"}}).catch(()=>{})})
await page.goto("/probes");await run(page).click();await expect.poll(()=>calls).toBe(1);await page.clock.fastForward(60_100)
await expect(page.getByText(/Время ожидания истекло/)).toBeVisible();await expect(run(page)).toBeEnabled();await expect(page.getByRole("textbox",{name:"Цель диагностики"})).toHaveValue("8.8.8.8");expect(calls).toBe(1);release()
})
test("Диагностика: уход со страницы не переносит поздний вывод",async({page})=>{
let first=true,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/api/servers/1/probes/run",async r=>{if(first){first=false;await gate;await r.fulfill({json:{output:"СТАРЫЙ ВЫВОД"}}).catch(()=>{})}else await r.fulfill({json:{output:"НОВЫЙ ВЫВОД"}})})
await page.goto("/probes");await run(page).click();await expect(run(page)).toBeDisabled();await page.goto("/terminal");release();await page.goto("/probes");await run(page).click();await expect(page.getByText("НОВЫЙ ВЫВОД",{exact:true})).toBeVisible();await expect(page.getByText("СТАРЫЙ ВЫВОД",{exact:true})).toHaveCount(0)
})