219 lines
15 KiB
TypeScript
219 lines
15 KiB
TypeScript
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, 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" ? { 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("—");
|
|
})
|