Files
MikrotikManager/tests/e2e/dashboard-data.spec.ts
T

249 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from "@playwright/test"
import { serverReadSchema } from "../../packages/contracts/src/servers"
import {dashboardGre,dashboardVxlan,dashboardWg} from "./fixtures/dashboard-overlays"
import {bgpSession} from "./fixtures/bgp"
async function setup(page: Page, initiallyFailed = false, serverCount = 1, initialBgpPayload?:unknown) {
const control = { failed: initiallyFailed, partial: false, prefixes: 321 as number|null, grePayload:undefined as unknown,wgPayload:undefined as unknown,vxlanPayload:undefined as unknown,serversPayload:undefined as unknown,probesPayload:undefined as unknown, trafficPayload: undefined as unknown, bgpPayload: initialBgpPayload as unknown, hold: false, holdPath: "/api/bgp/catalog", fallback: false, failFallback: false, pending: 0, release: () => {} }
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
control.release = release
const servers = Array.from({ length: serverCount }, (_, index) => ({ id: index + 1, name: serverCount === 1 ? "Узел" : `Узел ${index + 1}`, host: `192.0.2.${index + 1}`, site: "TEST", country: "RU", asn: "64512", type: "exit-node", enabled: true, status: serverCount === 1 ? "online" : "offline", latency: 10, os: "7", model: "CHR" })).map(row => serverReadSchema.parse({
port: 443, useSsl: true, verifySsl: true, username: "test", password: "fixture-only", comment: "", lanSubnet: "", wanUplinks: [],
createdAt: "2026-09-20T00:00:00Z", updatedAt: "2026-09-20T00:00:00Z", uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null, identityName: null, sessions: 0, polledAt: null, ...row,
}))
const snapshot = { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/auth/config") return route.fulfill({ json: { required: false } })
if (path === "/api/evobgp/settings") return route.fulfill({ json: { enabled: false, baseUrl: "", secretConfigured: false } })
const fail = control.failed || (control.failFallback && path === "/api/uptime/speed-probes") || (control.partial && ["/api/bgp/catalog", "/api/filters/gre-tunnels", "/api/events", "/api/internet-path/latest", "/api/traffic/servers"].includes(path))
const prefixes = control.prefixes
if (control.hold && path === control.holdPath) {
control.hold = false
control.pending += 1
await gate
}
if (fail) return route.fulfill({ status: 503, json: { error: "Источник временно недоступен" } })
const json = path === "/api/servers" ? control.serversPayload !== undefined ? control.serversPayload : servers
: path === "/api/uptime/overview" ? control.probesPayload !== undefined ? control.probesPayload : { probes: [] }
: path === "/api/bgp/catalog" ? control.bgpPayload !== undefined ? control.bgpPayload : {sessions:[bgpSession(1,prefixes)],failures:[]}
: path === "/api/filters/rules" ? { rulesets: [] }
: path === "/api/filters/gre-tunnels" ? control.grePayload!==undefined?control.grePayload:{ tunnels: [dashboardGre],failures:[] }
: path === "/api/wireguard" ? control.wgPayload!==undefined?control.wgPayload:{ interfaces: [],failures:[] }
: path === "/api/vxlan" ? control.vxlanPayload!==undefined?control.vxlanPayload:{ tunnels: [],failures:[] }
: path === "/api/traffic/servers" ? control.trafficPayload !== undefined ? control.trafficPayload : { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500],observations:{rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-09-20T00:00:00Z"} }] }
: path === "/api/internet-path/latest" ? { snapshot: control.fallback ? null : snapshot }
: path === "/api/uptime/speed-probes" ? { probes: [] }
: path === "/api/events" ? { events: [{ id: "e1", title: "Событие сохранено", message: "Тестовое событие", level: "info", sourceModule: "system", eventType: "test", createdAt: "2026-09-20T00:00:00Z", entityId: null, entityType: null, payload: {} }] } : {}
return route.fulfill({ json })
})
await page.goto("/dashboard")
return control
}
const refresh = (page: Page) => page.getByRole("button", { name: "Обновить данные", exact: true })
const bgp = (page: Page) => page.getByRole("link").filter({ hasText: "Префиксы BGP" })
test("Дашборд: очередь над измерениями, полный счётчик и раскрытие остальных проблем", async ({ page }) => {
await setup(page, false, 7)
await expect(refresh(page)).toBeEnabled()
const queue = page.getByRole("region", { name: "Требует внимания", exact: true })
await expect(queue.getByText("7", { exact: true })).toBeVisible()
const serverLinks = queue.getByRole("link", { name: /^Узел \d/ })
await expect(serverLinks).toHaveCount(5)
const more = queue.locator("summary").filter({ hasText: "Ещё 2" })
await more.focus()
await more.press("Enter")
await expect(serverLinks).toHaveCount(7)
await more.press("Enter")
await expect(serverLinks).toHaveCount(5)
const queueBox = await queue.boundingBox()
const measurementBox = await page.getByText("Задержка до серверов", { exact: true }).boundingBox()
expect(queueBox!.y + queueBox!.height).toBeLessThan(measurementBox!.y)
await expect(queue.getByText("Нет активных проверок", { exact: true })).toBeVisible()
await page.screenshot({ path: "test-results/dashboard-priority-desktop.png", animations: "disabled" })
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole("navigation", { name: "Путь страницы" }).scrollIntoViewIfNeeded()
await page.screenshot({ path: "test-results/dashboard-priority-mobile.png", animations: "disabled" })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await serverLinks.first().click()
await expect(page).toHaveURL(/\/servers\?server=1$/)
await expect(page.getByRole("status", { name: "Сервер по ссылке" })).toContainText("Выбран сервер: Узел 1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await page.goBack()
await expect(queue).toBeVisible()
})
test("Дашборд: первоначальная ошибка не подменяется нулями и исправным состоянием", async ({ page }) => {
const control = await setup(page, true)
await expect(refresh(page)).toBeEnabled()
await expect(page.getByText("Не обновлены источники: 9", { exact: true }).first()).toBeVisible()
await expect(page.getByRole("link").filter({ hasText: "Серверы онлайн" })).toContainText("—")
await expect(bgp(page)).toContainText("—")
await expect(page.getByText("Серверы без проблем", { exact: true })).toHaveCount(0)
await expect(page.getByText("Проверки без ошибок", { exact: true })).toHaveCount(0)
control.failed = false
await refresh(page).click()
await expect(bgp(page)).toContainText("321")
await expect(page.getByText("Не обновлены источники: 9", { exact: true })).toHaveCount(0)
})
test("Дашборд: частичный сбой сохраняет данные, события и время последнего успеха", async ({ page }) => {
const control = await setup(page)
await expect(refresh(page)).toBeEnabled()
await expect(bgp(page)).toContainText("321")
const source = page.locator('[data-source="bgp"]')
const receivedAt = await source.locator("time").getAttribute("datetime")
control.partial = true
await refresh(page).click()
await expect(source).toContainText("ошибка обновления")
await expect(source.locator("time")).toHaveAttribute("datetime", receivedAt!)
await expect(bgp(page)).toContainText("321")
await expect(page.getByText("Событие сохранено", { exact: true })).toBeVisible()
await expect(page.getByText("GRE сохранён", { exact: true })).toBeVisible()
await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка")
await page.screenshot({ path: "test-results/dashboard-data-desktop.png", fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
await expect(source).toBeVisible()
await page.screenshot({ path: "test-results/dashboard-data-mobile.png", fullPage: true })
control.partial = false
control.prefixes = 654
await refresh(page).click()
await expect(bgp(page)).toContainText("654")
await expect(page.getByText(/Не обновлены источники:/)).toHaveCount(0)
})
test("Дашборд: поздний ответ не заменяет свежие данные при фоновом обновлении", async ({ page }) => {
const control = await setup(page)
await expect(bgp(page)).toContainText("321")
await expect(refresh(page)).toBeEnabled()
control.hold = true
control.prefixes = 111
await refresh(page).click()
await expect.poll(() => control.pending).toBe(1)
await expect(bgp(page)).toContainText("321")
await expect(page.getByText("Обновление данных…", { exact: true })).toBeVisible()
control.prefixes = 999
await page.evaluate(() => window.dispatchEvent(new Event("mm:uptime-probes-changed")))
await expect(bgp(page)).toContainText("999")
const oldResponse = page.waitForResponse(response => response.url().includes("/api/bgp/catalog"))
control.release()
await (await oldResponse).finished()
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
await expect(refresh(page)).toBeEnabled()
await expect(bgp(page)).toContainText("999")
})
test("Internet path: ошибка дополнительных запросов сохраняет прежний снимок", async ({ page }) => {
const control = await setup(page)
await expect(refresh(page)).toBeEnabled()
const source = page.locator('[data-source="internetPath"]')
const timestamp = await source.locator("time").first().getAttribute("datetime")
control.fallback = true
control.failFallback = true
await refresh(page).click()
await expect(source).toContainText("Не удалось обновить все данные Internet path")
await expect(source.locator("time").first()).toHaveAttribute("datetime", timestamp!)
await expect(source.locator("time").last()).toHaveAttribute("datetime", "2026-09-20T00:00:00Z")
})
test("Internet path: поздний дополнительный запрос не заменяет новый снимок", async ({ page }) => {
const control = await setup(page)
await expect(refresh(page)).toBeEnabled()
control.fallback = true
control.holdPath = "/api/uptime/speed-probes"
control.hold = true
await refresh(page).click()
await expect.poll(() => control.pending).toBe(1)
control.fallback = false
await page.evaluate(() => window.dispatchEvent(new Event("mm:uptime-probes-changed")))
await expect(refresh(page)).toBeEnabled()
const source = page.locator('[data-source="internetPath"]')
const timestamp = await source.locator("time").first().getAttribute("datetime")
const response = page.waitForResponse(r => r.url().includes("/api/uptime/speed-probes"))
control.release()
await (await response).finished()
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
await expect(source.locator("time").first()).toHaveAttribute("datetime", timestamp!)
await expect(source.locator("time").last()).toHaveAttribute("datetime", "2026-09-20T00:00:00Z")
})
test('Сквозная приёмка: проблема → опрос сервера с отказом и повтором → возврат', async ({ page }) => {
const writes: string[] = []
page.on('request', request => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(new URL(request.url()).pathname)
})
await setup(page, false, 2)
let fail = true
await page.route('**/api/servers/2/poll', route => route.fulfill({
status: fail ? 403 : 200,
json: fail ? { error: 'Опрос запрещён политикой сервера' } : {},
}))
const queue = page.getByRole('region', { name: 'Требует внимания', exact: true })
await queue.getByRole('link', { name: /^Узел 2/ }).click()
await expect(page).toHaveURL(/\/servers\?server=2$/)
const context = page.getByRole('status', { name: 'Сервер по ссылке' })
await expect(context).toContainText('Узел 2')
await expect(page.locator('tr[data-row-id]')).toHaveCount(1)
expect(writes).toEqual([])
const poll = page.getByRole('button', { name: 'Проверить сервер', exact: true })
await poll.click()
await expect(page.getByText('Опрос: успешно 0 из 1', { exact: true })).toBeVisible()
await expect(context).toContainText('Узел 2')
fail = false
await poll.click()
await expect(page.getByText('Опрос: успешно 1 из 1', { exact: true })).toBeVisible()
expect(writes).toEqual(['/api/servers/2/poll', '/api/servers/2/poll'])
await page.goBack()
await expect(page).toHaveURL(/\/dashboard$/)
await expect(queue.getByRole('link', { name: /^Узел 2/ })).toBeVisible()
await page.goForward()
await expect(context).toContainText('Узел 2')
await expect(page.locator('tr[data-row-id]')).toHaveCount(1)
await expect(poll).toBeEnabled()
expect(writes).toEqual(['/api/servers/2/poll', '/api/servers/2/poll'])
})
for(const broken of ["missing","partial","duplicate","counter","conflict"])test("Dashboard strict BGP unknown and last good/"+broken,async({page})=>{
const sessions=[bgpSession()],failure={serverId:2,serverName:"Узел без ответа",error:"timeout"};const payload=broken==="missing"?{sessions:[]}:broken==="partial"?{sessions,failures:[failure]}:broken==="duplicate"?{sessions:[...sessions,...sessions],failures:[]}:broken==="counter"?{sessions:[bgpSession(1,-1)],failures:[]}:{sessions,failures:[{...failure,serverId:1}]};
const control=await setup(page,false,1,payload);const source=page.locator('[data-source="bgp"]');await expect(refresh(page)).toBeEnabled();await expect(source).toContainText("ошибка");await expect(bgp(page)).toContainText("—");
control.bgpPayload=undefined;await refresh(page).click();await expect(bgp(page)).toContainText("321");const receivedAt=await source.locator("time").getAttribute("datetime");control.bgpPayload=payload;await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);await expect(bgp(page)).toContainText("321");await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");
control.bgpPayload=undefined;control.prefixes=999;await refresh(page).click();await expect(bgp(page)).toContainText("999");await expect(source).not.toContainText("ошибка");
})
for(const unknown of ["prefix","state"])test("Dashboard BGP accepts observed unknown without inventing zero/"+unknown,async({page})=>{
const control=await setup(page);await expect(bgp(page)).toContainText("321");await expect(refresh(page)).toBeEnabled();control.bgpPayload={sessions:[unknown==="prefix"?bgpSession(1,null):{...bgpSession(),state:"Unknown"}],failures:[]};await refresh(page).click();await expect(bgp(page)).toContainText("—");await expect(bgp(page)).toContainText("префиксы неизвестны");await expect(bgp(page)).toContainText(unknown==="prefix"?"Сессий установлено: 1":"Сессий установлено: неизвестно");await expect(page.locator('[data-source="bgp"]')).not.toContainText("ошибка");
control.bgpPayload={sessions:[],failures:[]};await refresh(page).click();await expect(bgp(page)).toContainText("Сессий установлено: 0");await expect(bgp(page)).not.toContainText("—");
})
for(const broken of ["missing","duplicate","counter","null","length"])test("Dashboard traffic rejects unconfirmed totals and keeps last good/"+broken,async({page})=>{
const control=await setup(page);const source=page.locator('[data-source="traffic"]');await expect(refresh(page)).toBeEnabled();await expect(source.locator("time")).toHaveCount(2);const receivedAt=await source.locator("time").first().getAttribute("datetime");const rate={id:"1",rxNow:1000,txNow:500,rxSeries:[1000],txSeries:[500],observations:{rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-09-20T00:00:00Z"}};control.trafficPayload=broken==="missing"?{}:broken==="duplicate"?{servers:[rate,rate]}:broken==="counter"?{servers:[{...rate,rxNow:-1}]}:broken==="null"?{servers:[{...rate,rxNow:null}]}:{servers:[{...rate,txSeries:[]}]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time").first()).toHaveAttribute("datetime",receivedAt!);await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");control.trafficPayload={servers:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible();await expect(page.getByText("RX 0 Кбит/с",{exact:true})).toBeVisible();
})
for(const state of ["legacy","unobserved","measured-zero","reset-rx","invalid-time","invalid-mask"])test("Dashboard traffic observation metadata/"+state,async({page})=>{
const control=await setup(page);const source=page.locator('[data-source="traffic"]');await expect(refresh(page)).toBeEnabled();await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();const rate={id:"1",rxNow:0,txNow:0,rxSeries:[0],txSeries:[0]};const observations={rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-10-03T00:00:00Z"};control.trafficPayload={servers:[state==="legacy"?rate:{...rate,observations:state==="unobserved"?{rxNow:false,txNow:false,rxSeries:[false],txSeries:[false],sampledAt:null}:state==="reset-rx"?{...observations,rxNow:false,rxSeries:[false]}:state==="invalid-time"?{...observations,sampledAt:"invalid"}:state==="invalid-mask"?{...observations,rxSeries:[]}:observations}]};await refresh(page).click();await expect(refresh(page)).toBeEnabled();
if(state.startsWith("invalid")){await expect(source).toContainText("ошибка обновления");await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();return}
await expect(source).not.toContainText("ошибка");if(state==="measured-zero"){await expect(page.getByText("RX 0 Кбит/с",{exact:true})).toBeVisible();await expect(page.getByRole("region",{name:"История RX/TX",exact:true})).toBeVisible();await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toHaveCount(0)}else{await expect(page.getByText("RX —",{exact:true})).toBeVisible();if(state==="reset-rx"){await expect(page.getByText("TX 0 Кбит/с",{exact:true})).toBeVisible();await expect(page.getByText("Нет измерений RX за выбранный период",{exact:true})).toBeVisible()}else{await expect(page.getByText("TX —",{exact:true})).toBeVisible();await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible()}}
if(state==="legacy")await expect(page.getByText("Измерения не подтверждены API",{exact:true})).toBeVisible()
})
for(const kind of ["servers","probes"])for(const broken of ["missing","duplicate","numeric"])test("Dashboard strict catalog keeps source cache/"+kind+"/"+broken,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="'+kind+'"]');const receivedAt=await source.locator("time").getAttribute("datetime");const server={id:1,name:"Узел",host:"192.0.2.1",site:"TEST",country:"RU",asn:"64512",type:"exit-node",enabled:true,status:"online",latency:1,os:"7",model:"CHR",sessions:0};const probe={id:"p1",srcServerId:"1",name:"Тест",target:"192.0.2.2",filter:"main",rtt:1,loss:0,status:"up",series:[1],enabled:true,showOnDashboard:true};
if(kind==="servers")control.serversPayload=broken==="missing"?{}:broken==="duplicate"?[server,server]:[{...server,latency:-1}];else control.probesPayload=broken==="missing"?{}:broken==="duplicate"?{probes:[probe,probe]}:{probes:[{...probe,loss:101}]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);if(kind==="servers")await expect(page.getByRole("link").filter({hasText:"Серверы онлайн"})).toContainText("1 / 1");else await expect(page.getByText("Проверки без ошибок",{exact:true})).toHaveCount(0);
control.serversPayload=undefined;control.probesPayload=undefined;await refresh(page).click();await expect(source).not.toContainText("ошибка");
})
test("Dashboard unknown server status is not reported offline or healthy",async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();control.serversPayload=[{id:1,name:"Неопрошенный узел",host:"192.0.2.1",site:"TEST",country:"RU",asn:"64512",type:"exit-node",enabled:true,status:null,latency:null,os:null,model:null,sessions:0}];await refresh(page).click();const tile=page.getByRole("link").filter({hasText:"Серверы онлайн"});await expect(tile).toContainText("Статус неизвестен: 1");await expect(tile).not.toContainText("не в сети");await expect(page.getByRole("region",{name:"Требует внимания",exact:true}).getByRole("link",{name:/Неопрошенный узел/})).toContainText("Статус неизвестен");await expect(page.getByText("Серверы без проблем",{exact:true})).toHaveCount(0);await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");
})
for(const state of ["legacy","unmeasured","zero","failure","gaps","invalid"])test("Dashboard ping distinguishes observed and missing measurements/"+state,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const probe={id:"ping-observation",srcServerId:"1",name:"Измерение ping",target:"192.0.2.2",filter:"main",rtt:0,loss:0,status:"up",series:[0],enabled:true,showOnDashboard:true};const sample=(minute:number,rtt:number|null,status="up",loss=0)=>({sampledAt:"2026-10-03T00:"+String(minute).padStart(2,"0")+":00Z",rtt,status,loss});const samples=state==="unmeasured"?[]:state==="failure"?[sample(30,null,"down",100)]:state==="gaps"?[sample(29,5),sample(30,6),sample(32,0)]:state==="invalid"?[sample(30,0),sample(30,0)]:[sample(30,0)];control.probesPayload=state==="legacy"?{probes:[probe]}:{rangeStartAt:"2026-10-03T00:00:00Z",rangeEndAt:"2026-10-03T01:00:00Z",probes:[{...probe,observations:{samples}}]};await refresh(page).click();await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="probes"]');if(state==="invalid"){await expect(source).toContainText("ошибка обновления");return}await expect(source).not.toContainText("ошибка");const row=page.locator('tr[data-row-id]').filter({hasText:"Измерение ping"});await expect(row).toHaveCount(1);
if(state==="legacy"||state==="unmeasured"){await expect(row).toContainText("Неизвестно");await expect(page.getByRole("link").filter({hasText:"Проверки с проблемами"})).toContainText("неизвестны: 1");await expect(page.getByText("Проверки без ошибок",{exact:true})).toHaveCount(0);await expect(row).not.toContainText("100%");await expect(page.locator('[data-latency-source]')).toHaveCount(0)}else if(state==="failure"){await expect(row).toContainText("Оффлайн");await expect(row).toContainText("100%");await expect(page.getByRole("link").filter({hasText:"Проверки с проблемами"})).toContainText("1 ошибок")}else{await expect(row).toContainText("Онлайн");await expect(row).toContainText("0%");await expect(page.locator('[data-latency-isolated]')).toHaveCount(1);if(state==="gaps"){await expect(page.locator('.recharts-line-curve')).toHaveAttribute("d",/M.*M/);for(const width of [1440,320]){await page.setViewportSize({width,height:900});await page.getByRole("region",{name:"История RTT по источникам",exact:true}).scrollIntoViewIfNeeded();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);await page.screenshot({path:"docs/ux-ui/screens/dashboard-observed-ping-"+width+".png",animations:"disabled"})}}else await expect(page.locator('[data-latency-segment]')).toHaveCount(0);await expect(source.locator("time").last()).toHaveAttribute("datetime",new Date(samples.at(-1)!.sampledAt).toISOString())}
})
for(const kind of ["gre","wg","vxlan"] as const)for(const broken of ["missing","partial","duplicate"])test("Dashboard overlay complete source and cache/"+kind+"/"+broken,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="'+kind+'"]'),controlKey=kind==="gre"?"grePayload":kind==="wg"?"wgPayload":"vxlanPayload",rowsKey=kind==="wg"?"interfaces":"tunnels",row=kind==="gre"?dashboardGre:kind==="wg"?dashboardWg:dashboardVxlan;control[controlKey]={[rowsKey]:[row],failures:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");const receivedAt=await source.locator("time").getAttribute("datetime");control[controlKey]=broken==="missing"?{}:broken==="partial"?{[rowsKey]:[row],failures:[{serverId:"2",serverName:"Узел без ответа",error:"timeout"}]}:{[rowsKey]:[row,row],failures:[]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);await expect(page.getByRole("link").filter({hasText:"Туннели работают"})).toContainText("Последний успешный снимок");await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");if(kind==="gre")await expect(page.getByText("GRE сохранён",{exact:true})).toBeVisible();control[controlKey]={[rowsKey]:[],failures:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");if(kind==="gre")await expect(page.getByText("GRE сохранён",{exact:true})).toHaveCount(0)
})