import { test, expect, type Page } from "@playwright/test" const headers = { "access-control-allow-origin": "http://127.0.0.1:3101", "access-control-allow-credentials": "true", "access-control-allow-methods": "GET,PUT,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" } const levels = { critical: 1, error: 0, warning: 1, info: 1, debug: 0, unknown: 0 } const settings = { enabled: true, intervalSec: 10, retentionDays: 14 } const servers = [1, 2].map(id => ({ id, name: `MikroTik-${id}`, host: `192.0.2.${id}`, enabled: true, country: "RU", type: "home-router", status: "online" })) const row = (id: number, serverId = 1) => ({ id: String(id), serverId, serverName: `MikroTik-${serverId}`, host: `192.0.2.${serverId}`, routerId: `*${id}`, rawTime: "12:00:00", eventAt: null, receivedAt: new Date().toISOString(), effectiveAt: new Date().toISOString(), timeKind: "receipt", topics: ["system", id === 2 ? "warning" : id === 3 ? "error" : "info"], level: id === 2 ? "warning" : id === 3 ? "critical" : "info", message: `Событие ${id} сервера ${serverId} — interface ether1`, generation: 0 }) const state = (id: number) => ({ serverId: id, serverName: `MikroTik-${id}`, host: `192.0.2.${id}`, generation: 0, lastCollectedAt: new Date().toISOString(), lastError: null, gapAt: null, nextPollAt: null, failures: 0 }) async function setup(page: Page, options: { count?: number; historyError?: boolean } = {}) { const calls: URL[] = [] await page.route("**/health", route => route.fulfill({ headers, json: { status: "ok" } })) await page.route("**/api/**", route => { const url = new URL(route.request().url()), path = url.pathname calls.push(url) if (path === "/api/auth/config") return route.fulfill({ headers, json: { required: false } }) if (path === "/api/servers") return route.fulfill({ headers, json: servers }) if (path === "/api/logs/settings") return route.fulfill({ headers, json: route.request().method() === "PUT" ? { ok: true, settings: { ...settings, ...route.request().postDataJSON() } } : settings }) if (path === "/api/logs/summary") return route.fulfill({ headers, json: { settings, total: options.count ?? 3, levels, states: [state(1), state(2)], buckets: [{ at: new Date(Date.now() - 60000).toISOString(), ...levels }] } }) if (path === "/api/logs") return route.fulfill(options.historyError ? { headers, status: 503, json: { error: "История временно недоступна" } } : { headers, json: { entries: Array.from({ length: options.count ?? 3 }, (_, i) => row((options.count ?? 3) - i, Number(url.searchParams.get("serverId") ?? 1))), nextCursor: null, watermark: String(options.count ?? 3) } }) if (path === "/api/logs/live") return route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: `event: batch\ndata: ${JSON.stringify({ entries: [], cursor: url.searchParams.get("cursor"), state: state(Number(url.searchParams.get("serverId"))), enabled: true })}\n\n` }) if (/\/api\/logs\/\d+\/context$/.test(path)) { const id = Number(path.split("/").at(-2)); return route.fulfill({ headers, json: { entry: row(id), before: [row(1)], after: [row(4)] } }) } if (path === "/api/logs/export") return route.fulfill({ headers: { ...headers, "content-type": "text/csv", "content-disposition": 'attachment; filename="router-logs.csv"' }, body: "time,message\nnow,test" }) return route.fulfill({ headers, json: {} }) }) return calls } test("Логи: общий выбор серверов, история и детали", async ({ page }) => { const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)) const calls = await setup(page) await page.goto("/logs") await expect(page).toHaveURL(/server=1/) await expect(page.getByRole("heading", { name: "Логи MikroTik" })).toBeVisible() await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toBeVisible() await page.getByRole("button", { name: "Открыть событие 3", exact: true }).click() await expect(page.getByRole("dialog")).toBeVisible() await expect(page.getByRole("heading", { name: "Событие RouterOS" })).toBeVisible() await page.getByRole("tab", { name: "Исходные поля" }).click() await expect(page.getByRole("tabpanel")).toContainText('".id": "*3"') await page.getByRole("tab", { name: "Контекст", exact: true }).click() await expect(page.getByRole("tabpanel")).toContainText("Событие 4") await page.keyboard.press("Escape") await page.getByRole("option", { name: /MikroTik-2/ }).click() await expect(page).toHaveURL(/server=2/) await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toContainText("сервера 2") await expect.poll(() => calls.some(url => url.pathname === "/api/logs/live" && url.searchParams.get("serverId") === "2")).toBe(true) await page.getByRole("option", { name: /Все серверы/ }).click() await expect(page).toHaveURL(/server=all/) await expect(page.getByText("Все серверы · сохранённая история")).toBeVisible() expect(errors).toEqual([]) }) test("Логи: поиск в URL, представления и экспорт используют фильтры", async ({ page }) => { const calls = await setup(page) await page.goto("/logs?server=1&live=0") await page.getByRole("textbox", { name: "Поиск по сообщениям" }).fill("ether1") await page.getByRole("button", { name: "Найти события" }).click() await expect(page).toHaveURL(/q=ether1/) await expect.poll(() => calls.some(url => url.pathname === "/api/logs" && url.searchParams.get("q") === "ether1")).toBe(true) await expect(page.locator("mark").first()).toHaveText("ether1") await page.getByRole("textbox", { name: "Название представления" }).fill("Мой WAN") await page.getByRole("button", { name: "Сохранить", exact: true }).click() await expect(page.getByRole("button", { name: "Мой WAN", exact: true })).toBeVisible() const download = page.waitForEvent("download") await page.getByRole("button", { name: "CSV", exact: true }).click() await download expect(calls.find(url => url.pathname === "/api/logs/export")?.searchParams.get("q")).toBe("ether1") await page.reload() await expect(page.getByRole("button", { name: "Мой WAN", exact: true })).toBeVisible() }) test("Логи: пауза буферизует новые события", async ({ page }) => { await setup(page) let release!: () => void, started = false const gate = new Promise(resolve => { release = resolve }) await page.route("**/api/logs/live?**", async route => { if (started) return route.fallback() started = true; await gate await route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: `event: batch\ndata: ${JSON.stringify({ entries: [row(999)], cursor: "999", state: state(1), enabled: true })}\n\n` }).catch(() => {}) }) await page.goto("/logs?server=1") await expect.poll(() => started).toBe(true) await page.getByRole("button", { name: "Пауза", exact: true }).click() release() await expect(page.getByRole("button", { name: "Новых событий: 1", exact: true })).toBeVisible() await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toHaveCount(0) await page.getByRole("button", { name: "Новых событий: 1", exact: true }).click() await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toBeVisible() }) test("Логи: поздний ответ прежнего устройства не меняет новый журнал", async ({ page }) => { await setup(page) let release!: () => void, started = false const gate = new Promise(resolve => { release = resolve }) await page.route("**/api/logs?**", async route => { if (new URL(route.request().url()).searchParams.get("serverId") !== "1") return route.fallback() started = true; await gate await route.fulfill({ headers, json: { entries: [{ ...row(888), message: "Поздний ответ первого устройства" }], watermark: "888", nextCursor: null } }).catch(() => {}) }) await page.goto("/logs?server=1") await expect.poll(() => started).toBe(true) await page.getByRole("option", { name: /MikroTik-2/ }).click() await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toContainText("сервера 2") release() await expect(page.getByText("Поздний ответ первого устройства")).toHaveCount(0) }) for (const width of [1440, 390]) for (const theme of ["light", "dark"]) test(`Логи: ${width}px ${theme}, виртуализация 5000 строк`, async ({ page }) => { await page.setViewportSize({ width, height: 1000 }) await page.addInitScript(value => localStorage.setItem("rl-theme", value), theme) await setup(page, { count: 5000 }) await page.goto("/logs?server=1&live=0") await expect(page.getByRole("heading", { name: "Логи MikroTik" })).toBeVisible({ timeout: 15000 }) await expect(page.getByText("Показаны последние 5 000 загруженных событий.", { exact: false })).toBeVisible() expect(await page.locator("tbody tr").count()).toBeLessThan(60) expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) if (width === 390) { await page.getByRole("button", { name: "Серверы", exact: true }).click() await expect(page.getByRole("option", { name: /MikroTik-2/ })).toBeVisible() await page.keyboard.press("Escape") } await page.screenshot({ path: `test-results/logs-${width}-${theme}.png`, fullPage: true }) }) test("Логи: ошибка истории отображается с повтором", async ({ page }) => { await setup(page, { historyError: true }) await page.goto("/logs?server=1&live=0") await expect(page.getByText("История временно недоступна")).toBeVisible() await expect(page.getByRole("button", { name: "Повторить", exact: true })).toBeVisible() }) test("Логи: настройки сборщика сохраняют подтверждённые параметры", async ({ page }) => { await setup(page) const writes: unknown[] = [] await page.route("**/api/logs/settings", async route => { if (route.request().method() !== "PUT") return route.fallback() const payload = route.request().postDataJSON(); writes.push(payload) await route.fulfill({ headers, json: { ok: true, settings: { ...settings, ...payload } } }) }) await page.goto("/data-collection#router-logs") await expect(page.getByRole("textbox", { name: "Интервал сбора логов", exact: true })).toBeEnabled({ timeout: 15000 }) await page.getByRole("textbox", { name: "Интервал сбора логов", exact: true }).fill("20") await page.getByRole("textbox", { name: "Срок хранения логов", exact: true }).fill("7") await page.getByRole("button", { name: "Сохранить настройки логов", exact: true }).click() await expect(page.getByText("Настройки сборщика логов сохранены", { exact: true })).toBeVisible() expect(writes).toEqual([{ enabled: true, intervalSec: 20, retentionDays: 7 }]) await page.getByRole("textbox", { name: "Срок хранения логов", exact: true }).fill("0") await page.getByRole("button", { name: "Сохранить настройки логов", exact: true }).click() await expect(page.getByText("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней")).toBeVisible() expect(writes).toHaveLength(1) }) test("Логи: обслуживание БД требует нового снимка истории", async ({ page }) => { await setup(page) let connections = 0 await page.route("**/api/logs/live?**", route => { connections++ return route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: 'event: reset\ndata: {"reason":"database-maintenance"}\n\n' }) }) await page.goto("/logs?server=1") await expect(page.getByText("Поток остановлен для обслуживания БД.", { exact: false })).toBeVisible() expect(connections).toBe(1) await expect(page.getByRole("button", { name: "Повторить", exact: true })).toBeVisible() })