280 lines
15 KiB
TypeScript
280 lines
15 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: "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()
|
|
}
|
|
|
|
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)
|
|
})
|