Files
MikrotikManager/tests/e2e/servers.spec.ts
T

323 lines
18 KiB
TypeScript

import { test, expect, type Page, type Route } from "@playwright/test"
import { serverReadSchema, type ServerRead } from "../../packages/contracts/src/servers"
function server(id: number): ServerRead {
return serverReadSchema.parse({
id, name: `Проверка ${id}`, host: `192.0.2.${id}`, port: 443,
username: "test-user", password: "fixture-only", useSsl: true, verifySsl: true,
type: "exit-node", site: "TEST", country: id === 2 ? "DE" : "RU", asn: "", comment: "",
enabled: true, lanSubnet: "", wanUplinks: [],
createdAt: "2026-09-13T00:00:00Z", updatedAt: "2026-09-13T00:00:00Z",
status: "offline", latency: null, os: "7.20", model: "Test router",
uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null,
identityName: null, sessions: 0, polledAt: null,
})
}
async function setup(page: Page, intercept?: (route: Route, path: string) => Promise<boolean>, count = 2) {
const rows = Array.from({ length: count }, (_, i) => server(i + 1))
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 (await intercept?.(route, path)) return
const method = route.request().method()
let body: unknown = {}
if (path === "/api/auth/config") body = { required: false }
else if (path === "/api/servers") {
if (method === "POST") {
const added = { ...server(rows.length + 1), ...route.request().postDataJSON() }
rows.push(added); body = added
} else body = rows
} else if (/^\/api\/servers\/\d+$/.test(path)) {
const index = rows.findIndex(s => s.id === Number(path.split("/").pop()))
if (method === "DELETE") { rows.splice(index, 1); await route.fulfill({ status: 204 }); return }
if (method === "PUT") rows[index] = { ...rows[index], ...route.request().postDataJSON() }
body = rows[index]
}
await route.fulfill({ json: body })
})
await page.goto("/servers")
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
}
async function edit(page: Page, id = 1) {
await page.getByRole("button", { name: `Действия: Проверка ${id}`, exact: true }).click()
await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click()
}
test("таблица v9 сохраняет все строки, сортировку и раскрытие деталей", async ({ page }) => {
await setup(page, undefined, 12)
const rows = page.locator('tr[data-row-id]')
await expect(rows).toHaveCount(12)
await page.getByRole("button", { name: "Имя / Хост", exact: true }).click()
await expect(rows.first()).toContainText("Проверка 1")
await page.getByRole("button", { name: "Имя / Хост", exact: true }).click()
await expect(rows.first()).toContainText("Проверка 12")
await page.getByText("Проверка 12", { exact: true }).click()
await expect(page.getByText("Модель: Test router", { exact: true })).toBeVisible()
await page.getByText("Проверка 12", { exact: true }).click()
await expect(page.getByText("Модель: Test router", { exact: true })).toBeHidden()
})
async function finalStep(page: Page) {
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Далее", exact: true }).click()
}
test("фильтры сервера: применение, AND, изменение и сброс", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await setup(page)
await page.getByRole("button", { name: "Фильтры", exact: true }).click()
await page.getByRole("option", { name: "Статус", exact: true }).click()
await page.getByRole("option", { name: "равно", exact: true }).click()
await page.getByRole("option", { name: "Online", exact: true }).click()
await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Online", exact: true }).click()
await page.getByRole("option", { name: "Offline", exact: true }).click()
await expect(page.locator('tr[data-row-id]')).toHaveCount(2)
await page.getByRole("button", { name: "Фильтры", exact: true }).click()
await page.getByRole("option", { name: "Страна", exact: true }).click()
await page.getByRole("option", { name: "равно", exact: true }).click()
await page.getByRole("option", { name: "RU", exact: true }).click()
await expect(page.locator('tr[data-row-id]')).toHaveCount(1)
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
await page.screenshot({ path: "test-results/servers-desktop.png", fullPage: true, animations: "disabled" })
await page.getByRole("button", { name: "Очистить", exact: true }).click()
await expect(page.locator('tr[data-row-id]')).toHaveCount(2)
expect(errors).toEqual([])
})
test("мобильный эталон: клавиатура, Escape и раскрытие", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await setup(page)
const filters = page.getByRole("button", { name: "Фильтры", exact: true })
await filters.focus()
await page.keyboard.press("Enter")
await expect(page.getByRole("combobox", { name: "Поиск поля…" })).toBeFocused()
await page.keyboard.press("Escape")
await expect(filters).toBeFocused()
const expand = page.getByRole("button", { name: "Раскрыть: Проверка 1", exact: true })
await expand.focus()
await page.keyboard.press("Enter")
await expect(page.getByText("Модель: Test router", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Свернуть: Проверка 1", exact: true }).press("Enter")
await expect(page.getByText("Модель: Test router", { exact: true })).toBeHidden()
await page.screenshot({ path: "test-results/servers-mobile.png", fullPage: true, animations: "disabled" })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
})
for (const status of [400, 403, 500]) {
test(`ошибка сохранения ${status} сохраняет форму; повтор завершается успешно`, async ({ page }) => {
let fail = true
await setup(page, async (route, path) => {
if (path === "/api/servers/1" && route.request().method() === "PUT" && fail) {
await route.fulfill({ status, json: { error: "Сохранение отклонено" } }); return true
}
return false
})
await edit(page)
await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled()
await finalStep(page)
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(page.getByRole("alert")).toContainText(status === 403 ? "Недостаточно прав" : "Сохранение отклонено")
await expect(page.getByRole("dialog")).toBeVisible()
fail = false
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(page.getByRole("dialog")).toBeHidden()
await expect(page.getByText("Сервер сохранён.", { exact: true })).toBeVisible()
})
}
test("двойное сохранение отправляет один запрос и блокирует форму", async ({ page }) => {
let requests = 0
let finish!: () => void
const pending = new Promise<void>(resolve => { finish = resolve })
await setup(page, async (route, path) => {
if (path === "/api/servers/1" && route.request().method() === "PUT") {
requests++; await pending
await route.fulfill({ json: server(1) }); return true
}
return false
})
await edit(page); await finalStep(page)
await page.getByRole("button", { name: "Сохранить", exact: true }).evaluate((button: HTMLButtonElement) => { button.click(); button.click() })
await expect(page.getByRole("button", { name: "Сохранение…", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled()
await expect.poll(() => requests).toBe(1)
finish()
await expect(page.getByRole("dialog")).toBeHidden()
expect(requests).toBe(1)
})
test("ошибка удаления оставляет строку и подтверждение; повтор удаляет", async ({ page }) => {
let fail = true
await setup(page, async (route, path) => {
if (path === "/api/servers/1" && route.request().method() === "DELETE" && fail) {
await route.fulfill({ status: 500, json: { error: "Удаление отклонено" } }); return true
}
return false
})
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
await page.getByRole("menuitem", { name: "Удалить", exact: true }).click()
await expect(page.getByRole("alertdialog")).toContainText("Проверка 1")
await page.getByRole("button", { name: "Удалить", exact: true }).click()
await expect(page.getByRole("alertdialog")).toContainText("Удаление отклонено")
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
fail = false
await page.getByRole("button", { name: "Удалить", exact: true }).click()
await expect(page.getByRole("alertdialog")).toBeHidden()
await expect(page.getByText("Проверка 1", { exact: true })).toHaveCount(0)
})
test("отключение сохраняет offline-статус и отправляет enabled через API", async ({ page }) => {
let payload: unknown
await setup(page, async (route, path) => {
if (path === "/api/servers/1" && route.request().method() === "PUT") payload = route.request().postDataJSON()
return false
})
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
await page.getByRole("menuitem", { name: "Отключить", exact: true }).click()
await expect(page.getByText("Проверка 1: отключён.", { exact: true })).toBeVisible()
expect(payload).toEqual({ enabled: false })
await expect(page.getByText("Оффлайн", { exact: true })).toHaveCount(2)
})
test("ошибка загрузки деталей блокирует сохранение и позволяет повтор", async ({ page }) => {
let fail = true
await setup(page, async (route, path) => {
if (path === "/api/servers/1" && route.request().method() === "GET" && fail) {
await route.fulfill({ status: 403, json: { error: "Forbidden" } }); return true
}
return false
})
await edit(page)
await expect(page.getByRole("alert")).toContainText("Недостаточно прав")
await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeDisabled()
fail = false
await page.getByRole("button", { name: "Повторить загрузку", exact: true }).click()
await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled()
})
test("поздний ответ первого сервера не перезаписывает второй", async ({ page }) => {
let finish!: () => void
const pending = new Promise<void>(resolve => { finish = resolve })
await setup(page, async (route, path) => {
if (path === "/api/servers/1" && route.request().method() === "GET") {
await pending; await route.fulfill({ json: { ...server(1), username: "wrong-user" } }); return true
}
return false
})
await edit(page)
await expect(page.getByText("Загрузка параметров сервера…")).toBeVisible()
await page.getByRole("button", { name: "Отмена", exact: true }).click()
await edit(page, 2)
await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled()
finish()
await page.getByRole("button", { name: "Далее", exact: true }).click()
await page.getByRole("button", { name: "Далее", exact: true }).click()
await expect(page.getByPlaceholder("api-user", { exact: true })).toHaveValue("test-user")
})
test("массовый опрос ограничен четырьмя запросами и повторяет только ошибки", async ({ page }) => {
let active = 0, maximum = 0
const calls: string[] = []
let fail = true
await setup(page, async (route, path) => {
if (path.endsWith("/poll")) {
calls.push(path); maximum = Math.max(maximum, ++active)
await new Promise(resolve => setTimeout(resolve, 100))
active--
await route.fulfill({ status: fail && path === "/api/servers/2/poll" ? 500 : 200, json: { error: "Опрос недоступен" } })
return true
}
return false
}, 6)
await page.getByRole("button", { name: "Проверить все", exact: true }).click()
await expect(page.getByText("Опрос: успешно 5 из 6", { exact: true })).toBeVisible()
expect(maximum).toBeLessThanOrEqual(4)
expect(calls).toHaveLength(6)
fail = false
await page.getByRole("button", { name: "Повторить неуспешные", exact: true }).click()
await expect(page.getByText("Опрос: успешно 1 из 1", { exact: true })).toBeVisible()
expect(calls.slice(6)).toEqual(["/api/servers/2/poll"])
})
test("таймаут сохранения сохраняет форму и показывает неопределённый результат", async ({ page }) => {
// Speed up only the browser's server-operation deadline, keeping real fetch/AbortSignal.
await page.addInitScript(() => {
const timeout = AbortSignal.timeout.bind(AbortSignal)
AbortSignal.timeout = (ms: number) => timeout(ms === 30_000 ? 500 : ms)
})
await setup(page, async (route, path) => {
if (path === "/api/servers/1" && route.request().method() === "PUT") {
await new Promise(resolve => setTimeout(resolve, 1500))
await route.fulfill({ json: server(1) }).catch(() => {})
return true
}
return false
})
await edit(page); await finalStep(page)
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(page.getByRole("alert")).toContainText("Результат операции неизвестен")
await expect(page.getByRole("button", { name: "Сохранить", exact: true })).toBeEnabled()
})
test("создание сервера проходит через API и добавляет подтверждённую запись", async ({ page }) => {
await setup(page)
await page.getByRole("button", { name: "Добавить сервер", exact: true }).click()
await page.getByRole("button", { name: "Далее", exact: true }).click()
await page.getByRole("button", { name: "Далее", exact: true }).click()
await page.getByPlaceholder("203.0.113.1", { exact: true }).fill("192.0.2.99")
await page.getByPlaceholder("api-user", { exact: true }).fill("fixture-user")
await page.getByRole("button", { name: "Далее", exact: true }).click()
await page.getByRole("dialog").getByRole("button", { name: "Добавить сервер", exact: true }).click()
await expect(page.getByRole("dialog")).toBeHidden()
await expect(page.getByRole("button", { name: "Действия: 192.0.2.99", exact: true })).toHaveCount(1)
})
test("успешный опрос и ошибка обновления списка имеют раздельные результаты", async ({ page }) => {
let polled = false
await setup(page, async (route, path) => {
if (path.endsWith("/poll")) { polled = true; await route.fulfill({ json: {} }); return true }
if (path === "/api/servers" && polled) {
await route.fulfill({ status: 500, json: { error: "Список недоступен" } }); return true
}
return false
})
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
await page.getByRole("menuitem", { name: "Опросить", exact: true }).click()
await expect(page.getByText("Опрос: успешно 1 из 1", { exact: true })).toBeVisible()
await expect(page.getByRole("alert").filter({ hasText: "Действие не завершено" })).toContainText("Опрос завершён, но список не обновлён")
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
})
test("ошибка отключения не меняет enabled", async ({ page }) => {
await setup(page, async (route, path) => {
if (path === "/api/servers/1" && route.request().method() === "PUT") {
await route.fulfill({ status: 403, json: { error: "Forbidden" } }); return true
}
return false
})
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
await page.getByRole("menuitem", { name: "Отключить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Действие не завершено" })).toContainText("Недостаточно прав")
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
await expect(page.getByRole("menuitem", { name: "Отключить", exact: true })).toBeVisible()
})
test("изменение подключения сбрасывает результат его проверки", async ({ page }) => {
await setup(page, async (route, path) => {
if (path.endsWith("/test-connection")) {
await route.fulfill({ json: { success: true, message: "Проверено тестовым API" } }); return true
}
return false
})
await edit(page)
await page.getByRole("button", { name: "Далее", exact: true }).click()
await page.getByRole("button", { name: "Далее", exact: true }).click()
await page.getByRole("button", { name: "Проверить подключение", exact: true }).click()
await expect(page.getByText("Проверено тестовым API", { exact: true })).toBeVisible()
await page.getByPlaceholder("api-user", { exact: true }).fill("changed-user")
await expect(page.getByText("Проверено тестовым API", { exact: true })).toHaveCount(0)
})