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

223 lines
17 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 {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, 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" ? servers
: path === "/api/uptime/overview" ? { 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" ? { tunnels: [{ id: "g1", name: "GRE сохранён", status: "down", serverId: "1" }] }
: path === "/api/wireguard" ? { interfaces: [], peers: [] }
: path === "/api/vxlan" ? { tunnels: [] }
: path === "/api/traffic/servers" ? control.trafficPayload !== undefined ? control.trafficPayload : { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500] }] }
: 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(1);const receivedAt=await source.locator("time").getAttribute("datetime");const rate={id:"1",rxNow:1000,txNow:500,rxSeries:[1000],txSeries:[500]};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")).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();
})