diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx index 71192bcd..4809d8c8 100644 --- a/app/(main)/probes/page.tsx +++ b/app/(main)/probes/page.tsx @@ -1,5 +1,6 @@ "use client" +import { toast } from "sonner" import { Suspense, useEffect, useRef, useState, useMemo, useCallback } from "react" import { useSearchParams } from "next/navigation" import { readProbeSource } from "@/lib/probe-source" @@ -30,7 +31,7 @@ import { useDataSource } from "@/lib/data-source" import { PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon, ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon, - ZapIcon, ClockIcon, CheckIcon, TerminalIcon, + ZapIcon, CheckIcon, TerminalIcon, LoaderCircleIcon, AlertCircleIcon, } from "lucide-react" import { cn } from "@/lib/utils" @@ -72,12 +73,12 @@ const TOOL_META: Record = { - ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" }, - traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" }, - bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" }, - dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-amber-500", description: "DNS на хосте приложения" }, - route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" }, - mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" }, + ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-info", description: "Проверка связи и RTT" }, + traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-info", description: "Трассировка маршрута" }, + bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-success", description: "Пропускная способность" }, + dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-warning", description: "DNS на хосте приложения" }, + route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-info", description: "Поиск активного маршрута" }, + mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-warning", description: "Определение MTU пути" }, } function makeApiFetch(backendUrl: string) { @@ -86,17 +87,23 @@ function makeApiFetch(backendUrl: string) { } } +async function copyProbeText(text:string){ + try{await navigator.clipboard.writeText(text);toast.success("Скопировано.")} + catch{toast.error("Не удалось скопировать. Разрешите доступ к буферу обмена или выделите текст вручную.")} +} + function inferProbeLineKind(line: string): OutputLine["kind"] { const l = line.toLowerCase() if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err" - if (l.includes("sent=") || l.includes("received=") || l.includes("packet-loss") || l.includes("✓")) return "ok" + if (l.includes("✓")) return "ok" if (l.startsWith(";;") || l.trim() === "") return "dim" if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header" return "normal" } function parseProbeOutput(text: string): OutputLine[] { - return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) })) + const lines = text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) })) + return lines.length > 1000 ? [{ text: "Показаны последние 1000 строк; начало вывода сокращено.", kind: "dim" }, ...lines.slice(-1000)] : lines } @@ -325,11 +332,11 @@ function buildCommand( // ─── small UI components ────────────────────────────────────────────────────── -function NativeSelect({ value, onChange, children, className }: { - value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string +function NativeSelect({ value, onChange, children, className, ariaLabel }: { + value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; ariaLabel?: string }) { return ( - onChange(e.target.value)} className={cn( "h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground", "transition-colors outline-none", @@ -341,7 +348,8 @@ function NativeSelect({ value, onChange, children, className }: { ) } -function OptionLabel({ children }: { children: React.ReactNode }) { +function OptionLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) { + if (htmlFor) return return

{children}

} @@ -349,9 +357,9 @@ function SegBtn({ value, current, onClick, children } value: T; current: T; onClick: (v: T) => void; children: React.ReactNode }) { return ( - )} @@ -703,7 +681,8 @@ function ProbesContent() { const [tab, setTab] = useState<"history" | "schedule">("history") const [rules, setRules] = useState(INIT_RULES) const nextId = useRef(1) - const tickRef = useRef | null>(null) + const targetRef = useRef(null) + const runButtonRef = useRef(null) /** Живой POST /probes/run — отмена через fetch AbortSignal */ const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null) @@ -741,25 +720,6 @@ function ProbesContent() { bwTarget: bwTun?.remoteAddress, bwProto, bwDuration, }, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4]) - // ── progressive reveal tick ── - useEffect(() => { - tickRef.current = setInterval(() => { - setTests(prev => { - const hasRunning = prev.some(t => t.status === "running") - if (!hasRunning) return prev - return prev.map(t => { - if (t.source === "live") return t - if (t.status !== "running") return t - const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1 - const nextTotal = Math.min(t.totalLines + speed, t.lines.length) - const done = nextTotal >= t.lines.length - return { ...t, totalLines: nextTotal, status: done ? "done" : "running" } - }) - }) - }, 400) - return () => { if (tickRef.current) clearInterval(tickRef.current) } - }, []) - // ── run test ── const runTest = useCallback(async () => { const srv = allServers.find(s => s.id === srcId) @@ -856,7 +816,10 @@ function ProbesContent() { clearTimeout(timeout) if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null runLock.current = false - if (alive.current) setRunBusy(false) + if (alive.current) { + setRunBusy(false) + requestAnimationFrame(() => { if (alive.current && (document.activeElement === runButtonRef.current || document.activeElement === document.body)) (targetRef.current ?? runButtonRef.current)?.focus() }) + } } return } @@ -905,7 +868,6 @@ function ProbesContent() { traceHops, dnsType, bwTun, - bwTunId, bwProto, bwDuration, cmdPreview, @@ -921,6 +883,7 @@ function ProbesContent() { const clearTest = (id: string) => { if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort() setTests(p => p.filter(t => t.id !== id)) + runButtonRef.current?.focus() } const running = tests.filter(t => t.status === "running") @@ -932,7 +895,7 @@ function ProbesContent() {
- +
@@ -958,7 +921,7 @@ function ProbesContent() { ? : null} @@ -980,10 +943,11 @@ function ProbesContent() { {isLive && catalogReadAt !== null &&

Каталог получен: {new Date(catalogReadAt).toLocaleTimeString("ru-RU")}{liveLoad === "loading" ? " · обновляется" : ""}

} {isLive && liveLoad === "idle" && allServers.length === 0 &&

В каталоге нет устройств. Добавьте устройство в разделе «Серверы».

} + {!isLive &&

Демо: запросы не отправляются; результаты синтетические.

} {isLive && ( -

- Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik. -

+
Где выполняются тесты

+ Сетевые тесты выполняются на выбранном устройстве. DNS использует резолвер приложения. Traceroute ограничен примерно минутой; сокращение числа хопов указывается в ответе. Стоп отменяет ожидание, но не подтверждает остановку на устройстве. +

)} {!canRun &&

Для запуска диагностики требуется mm:servers:write.

} @@ -1002,26 +966,26 @@ function ProbesContent() { {sourceCurrent?.error ?

{sourceCurrent.error}

: !sourceCurrent || sourceCurrent.loading ?

Определяем IPv4 для preview…

:

{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}

} -

Preview справочный. Backend повторно определяет источник при запуске; пустой результат не подтверждает исходный адрес команды.

+

Адрес справочный: при запуске определяется заново.

)} {/* ── tool selector + config ── */} - + {/* tool chips */}
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => { const active = tool === t return ( - ) @@ -1034,12 +998,12 @@ function ProbesContent() { {/* target — all tools except bandwidth */} {tool !== "bandwidth" && (
- - {tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"} + + {tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель (IP или домен)"} - setTarget(e.target.value)} + setTarget(e.target.value)} placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"} - aria-label={tool === "dns" ? "Домен" : "Цель диагностики"} + aria-label={tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель диагностики"} className="font-mono text-sm" onKeyDown={e => e.key === "Enter" && runTest()} /> @@ -1050,7 +1014,7 @@ function ProbesContent() { {tool === "bandwidth" && (
GRE-туннель (цель) - + {bwTunnels.map(t => )}
@@ -1060,14 +1024,14 @@ function ProbesContent() { {tool === "ping" && ( <>
- Кол-во -
+ Количество пакетов +
{[5, 10, 25, 100].map(n => {n})}
Размер (байт) -
+
{[64, 128, 512, 1472].map(n => {n})}
@@ -1089,7 +1053,7 @@ function ProbesContent() { Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.

- +
)} @@ -1098,7 +1062,7 @@ function ProbesContent() {
Тип записи
- {(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => {t})} + {(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"] as DnsType[]).map(t => {t})}
)} @@ -1120,25 +1084,25 @@ function ProbesContent() { )} -
{/* RouterOS command preview */} -
- - [{tool === "dns" ? (isLive ? "backend" : "демо") : srcServer?.name}] - $ - {cmdPreview} -
@@ -1150,7 +1114,7 @@ function ProbesContent() { ["history", "История тестов"], ["schedule", "Расписание"], ] as const).map(([t, label]) => ( - )} - {test.status === "done" && test.source !== "live" && ( - - {((test.lines.length * 0.4)).toFixed(1)}с - - )} - {test.status === "done" && test.source === "live" && ( - live - )} + {test.status === "done" && {test.source === "live" ? "ответ API" : "демо"}} {test.status === "cancelled" && ожидание отменено} {test.status === "error" && ( - ошибка + ошибка )} - -
{/* terminal output */} -
+
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index e15c65a9..bbdc4c84 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -199,3 +199,13 @@ REST запрос → ожидание → подтверждённый output / | --- | --- | --- | | Готовность | [PNG](ux-ui/screens/terminal-ready-desktop.png) | [390 px](ux-ui/screens/terminal-ready-mobile.png), [320 px](ux-ui/screens/terminal-ready-narrow.png) | | Вывод | [PNG](ux-ui/screens/terminal-output-desktop.png) | [390 px](ux-ui/screens/terminal-output-mobile.png), [320 px](ux-ui/screens/terminal-output-narrow.png) | + + +### Диагностика: экраны 10.69 + +Референс — существующий ReUI Frame/FramePanel и List 9. Устройство → параметры → подтверждённый ответ API/ошибка → копирование или удаление. Расписание относится к 11Б. + +| Экран | ПК | Телефон | +| --- | --- | --- | +| Готовность | [PNG](ux-ui/screens/probes-ready-desktop.png) | [390 px](ux-ui/screens/probes-ready-mobile.png), [320 px](ux-ui/screens/probes-ready-narrow.png) | +| Вывод | [PNG](ux-ui/screens/probes-output-desktop.png) | [390 px](ux-ui/screens/probes-output-mobile.png), [320 px](ux-ui/screens/probes-output-narrow.png) | diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index e27fd886..9d81599a 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,7 +26,7 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.68; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.69; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 10.69 — доступность и адаптивность диагностики (2026-10-01) + +- Сохранена композиция Frame/FramePanel, OpsPanel и ServerRail; API Frame и ReUI checklist сверены. Vendor/theme не изменены. Цвета диагностики используют семантические токены, анимация учитывает reduced motion, кнопки/переключатели и область вывода имеют доступные имена и состояния. +- Вывод ограничен последними 1000 строками с явной отметкой; переносится и прокручивается внутри панели с высотой родителя. Удаление возвращает фокус на запуск, clipboard отказ показывает ошибку и сохраняет данные. Демо явно подписано; вымышленные время и положительная окраска неизвестных счётчиков убраны. +- На 1440/390/320 px нет горизонтального переполнения; команда на телефоне использует всю доступную ширину, адрес цели читается, DNS подписан как тест приложения. Сохранены и визуально проверены шесть снимков ready/output. +- TypeScript, 37 data/source E2E, 4 layout/clipboard E2E и 4 context E2E прошли. Production frontend build прошёл. Lint изменённого теста чистый; в странице остаются четыре прежние set-state-in-effect ошибки (три в расписании, одна в выборе BW). Новых ошибок/предупреждений нет. +- Далее 10.70: убрать эффект выбора BW в диагностике, дополнить проверку клавиатуры/успешного clipboard и локальную приёмку. Расписание и три его lint ошибки перенесены в 11Б; реальные RouterOS/БД/портал не приняты по мокам. + ## Блок 10.68 — отмена backend диагностики (2026-10-01) - Abort привязан к aborted HTTP запросу/незавершённому response close; нормальное завершение чтения тела не считается отменой. Listeners снимаются в finally. Перед/после зависимых await проверяется сигнал; отмена не начинает новые MTU пробы. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 6917aa11..7438b188 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -433,3 +433,12 @@ Shared контракт и backend observed исходные значения/д - Полный backend npm test завершился exit 0; DB-зависимые тесты явно skipped из-за ECONNREFUSED PostgreSQL 127.0.0.1:5432, не приняты по этому прогону. test:probes/build/profile lint повторены после транспортного теста. Mikrotik service сохраняет две исходные prefer-const ошибки 117/874; ESLint HEAD/current совпал, suppressions не добавлены. - Далее 10.69: доступность и адаптивность диагностики, семантические токены, подписи/вывод/clipboard и снимки; затем профильная регрессия и локальная приёмка /probes. Расписание в 11Б, реальные RouterOS/БД/портал остаются внешними ограничениями. + + +## Блок 10.69 — доступность и адаптивность диагностики (2026-10-01) + +- Сохранена композиция Frame/FramePanel, OpsPanel и ServerRail; API Frame и ReUI checklist сверены. Vendor/theme не изменены. Цвета диагностики используют семантические токены, анимация учитывает reduced motion, кнопки/переключатели и область вывода имеют доступные имена и состояния. +- Вывод ограничен последними 1000 строками с явной отметкой; переносится и прокручивается внутри панели с высотой родителя. Удаление возвращает фокус на запуск, clipboard отказ показывает ошибку и сохраняет данные. Демо явно подписано; вымышленные время и положительная окраска неизвестных счётчиков убраны. +- На 1440/390/320 px нет горизонтального переполнения; команда на телефоне использует всю доступную ширину, адрес цели читается, DNS подписан как тест приложения. Сохранены и визуально проверены шесть снимков ready/output. +- TypeScript, 37 data/source E2E, 4 layout/clipboard E2E и 4 context E2E прошли. Production frontend build прошёл. Lint изменённого теста чистый; в странице остаются четыре прежние set-state-in-effect ошибки (три в расписании, одна в выборе BW). Новых ошибок/предупреждений нет. +- Далее 10.70: убрать эффект выбора BW в диагностике, дополнить проверку клавиатуры/успешного clipboard и локальную приёмку. Расписание и три его lint ошибки перенесены в 11Б; реальные RouterOS/БД/портал не приняты по мокам. diff --git a/docs/ux-ui/screens/probes-output-desktop.png b/docs/ux-ui/screens/probes-output-desktop.png new file mode 100644 index 00000000..0e04eaba Binary files /dev/null and b/docs/ux-ui/screens/probes-output-desktop.png differ diff --git a/docs/ux-ui/screens/probes-output-mobile.png b/docs/ux-ui/screens/probes-output-mobile.png new file mode 100644 index 00000000..7801c04f Binary files /dev/null and b/docs/ux-ui/screens/probes-output-mobile.png differ diff --git a/docs/ux-ui/screens/probes-output-narrow.png b/docs/ux-ui/screens/probes-output-narrow.png new file mode 100644 index 00000000..c9818675 Binary files /dev/null and b/docs/ux-ui/screens/probes-output-narrow.png differ diff --git a/docs/ux-ui/screens/probes-ready-desktop.png b/docs/ux-ui/screens/probes-ready-desktop.png new file mode 100644 index 00000000..0b70e358 Binary files /dev/null and b/docs/ux-ui/screens/probes-ready-desktop.png differ diff --git a/docs/ux-ui/screens/probes-ready-mobile.png b/docs/ux-ui/screens/probes-ready-mobile.png new file mode 100644 index 00000000..71f5a1b9 Binary files /dev/null and b/docs/ux-ui/screens/probes-ready-mobile.png differ diff --git a/docs/ux-ui/screens/probes-ready-narrow.png b/docs/ux-ui/screens/probes-ready-narrow.png new file mode 100644 index 00000000..e132d95b Binary files /dev/null and b/docs/ux-ui/screens/probes-ready-narrow.png differ diff --git a/tests/e2e/probes-layout.spec.ts b/tests/e2e/probes-layout.spec.ts new file mode 100644 index 00000000..a37f9e89 --- /dev/null +++ b/tests/e2e/probes-layout.spec.ts @@ -0,0 +1,20 @@ +import {test,expect} from "@playwright/test" +for(const [name,width] of [["desktop",1440],["mobile",390],["narrow",320]] as const)test("Диагностика: адаптивность и длинный вывод "+name,async({page})=>{ + await page.setViewportSize({width,height:900});const device={id:1,name:"Очень длинное имя устройства диагностики для проверки мобильной кнопки",host:"192.0.2.1",country:"RU",enabled:true,status:null,type:"exit-node"} + 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"?[device]:p==="/api/auth/config"?{required:false}:p.endsWith("/ros-src-address")?{host:"192.0.2.1",ipv4:"192.0.2.1"}:{tunnels:[]}})}) + const output=Array.from({length:1200},(_,i)=>"строка-"+i+" "+"x".repeat(160)).join("\n") + await page.route("**/api/servers/1/probes/run",r=>r.fulfill({json:{output}}));await page.goto("/probes?server=1");await expect(page.getByRole("button",{name:"Запустить",exact:true})).toBeEnabled();await expect(page.getByRole("status").filter({hasText:"IPv4 для preview:"})).toBeVisible() + await page.getByRole("button",{name:"DNS",exact:true}).click();await expect(page.getByRole("button",{name:"DNS",exact:true})).toHaveAttribute("aria-pressed","true");await page.getByRole("button",{name:"CNAME",exact:true}).click();await expect(page.getByRole("button",{name:"CNAME",exact:true})).toHaveAttribute("aria-pressed","true");await page.getByRole("button",{name:"Ping",exact:true}).click() + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true) + if(width<768){const button=page.getByRole("button",{name:"Серверы",exact:true});const box=await button.boundingBox();expect(box&&box.x+box.width<=width).toBe(true);await button.click();await page.keyboard.press("Escape");await expect(button).toBeFocused()} + await page.screenshot({path:"docs/ux-ui/screens/probes-ready-"+name+".png",fullPage:true}) + await page.getByRole("button",{name:"Запустить",exact:true}).click();const region=page.getByRole("region",{name:/^Вывод Ping/});await expect(region).toContainText("строка-1199");await expect(region).toContainText("Показаны последние 1000 строк");await expect(region).not.toContainText("строка-199 ");await region.scrollIntoViewIfNeeded();await region.focus() + expect(await region.evaluate(el=>el.clientHeight>100&&el.scrollHeight>el.clientHeight&&el.scrollWidth<=el.clientWidth+1)).toBe(true);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true) + await page.screenshot({path:"docs/ux-ui/screens/probes-output-"+name+".png",fullPage:true}) + await page.getByRole("button",{name:/^Удалить результат/}).click();await expect(region).toHaveCount(0);await expect(page.getByRole("button",{name:"Запустить",exact:true})).toBeFocused() +}) +test("Диагностика: недоступный clipboard сохраняет вывод и сообщает ошибку",async({page})=>{ + await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/servers"?[{id:1,name:"Тест",host:"192.0.2.1",enabled:true,status:null,type:"exit-node"}]:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:{tunnels:[]}})) + await page.addInitScript(()=>Object.defineProperty(navigator,"clipboard",{value:{writeText:()=>Promise.reject(new Error("permission denied"))},configurable:true})) + await page.goto("/probes?server=1");await page.getByRole("button",{name:"Скопировать параметры диагностики"}).click();await expect(page.getByText(/Не удалось скопировать/)).toBeVisible();await expect(page.getByRole("textbox",{name:"Цель диагностики"})).toHaveValue("8.8.8.8") +})