feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
import {test,expect,type Page} from "@playwright/test"
import axe from "axe-core"
import {alertsWithRule,emptyOutbox} from "./fixtures/alerts"
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
async function setup(page:Page,theme:string){
await page.addInitScript(value=>localStorage.setItem("rl-theme",value),theme)
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;const body=path==="/api/auth/config"?{required:false}:path==="/api/alerts"?alertsWithRule:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/evobgp/settings"?evoSettings:path==="/api/evobgp/catalog"?evoCatalog:path==="/api/sidebar-counts"?{servers:0,filterRules:0,uptimeProbes:0,uptimeSpeedProbes:0,monitoringItems:0,recursiveRoutes:0}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:path==="/api/servers"||path==="/api/bgp/sessions"?[]:null;return body!==null?r.fulfill({json:body}):r.fulfill({status:503,json:{error:"Fixture unavailable"}})})
}
async function settleAnimations(page:Page){await page.evaluate(async()=>{await Promise.allSettled(document.getAnimations().filter(a=>a.effect?.getComputedTiming().iterations!==Infinity).map(a=>a.finished))})}
for(const theme of ["dark","light"])for(const width of [320,1440])for(const path of ["/alerts","/settings","/releases"])test("Automated WCAG tagged audit/"+theme+"/"+width+path,async({page},info)=>{
await setup(page,theme);await page.setViewportSize({width,height:900});await page.goto(path)
if(path==="/alerts")await expect(page.getByRole("textbox",{name:"Поиск правил",exact:true})).toBeVisible()
else if(path==="/settings"){await expect(page.getByRole("button",{name:"Тёмная",exact:true})).toBeEnabled();await expect(page.getByRole("button",{name:"Скачать",exact:true})).toBeEnabled()}
else await expect(page.getByText("Текущая версия",{exact:true})).toBeVisible()
await settleAnimations(page);await page.addScriptTag({content:axe.source})
const result=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run({include:["#main-content",'[aria-label="Основная навигация"]']},{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})})
await info.attach("axe-results",{body:JSON.stringify(result,null,2),contentType:"application/json"})
expect(result.violations.map(v=>({id:v.id,impact:v.impact,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([])
})
test("Shell keyboard skip link moves focus to main and menus restore focus",async({page})=>{
await setup(page,"dark");await page.goto("/releases");await expect(page.getByText("Текущая версия",{exact:true})).toBeVisible();await page.keyboard.press("Tab");const skip=page.getByRole("link",{name:"К содержимому",exact:true});await expect(skip).toBeFocused();await page.keyboard.press("Enter");await expect(page.locator("#main-content")).toBeFocused()
const apps=page.getByRole("button",{name:"Приложения",exact:true});await apps.focus();await page.keyboard.press("Enter");await expect(page.getByRole("menu")).toBeVisible();await page.keyboard.press("Escape");await expect(apps).toBeFocused()
const monitor=page.getByRole("button",{name:"Монитор системы",exact:true});await monitor.focus();await page.keyboard.press("Enter");await expect(page.locator('[data-slot="popover-content"]')).toBeVisible();await page.keyboard.press("Escape");await expect(monitor).toBeFocused()
})
test("System monitor recognizes confirmed legacy health consistently",async({page})=>{await setup(page,"dark");await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("API доступен")})
for(const theme of ["dark","light"])for(const width of [320,1440])test("Open shell layers audit/"+theme+"/"+width,async({page},info)=>{
await setup(page,theme);await page.setViewportSize({width,height:900});await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("API доступен");await page.getByRole("button",{name:"Монитор системы",exact:true}).click();await expect(page.locator('[data-slot="popover-content"]')).toBeVisible();await page.addScriptTag({content:axe.source});
const result=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run('[data-slot="popover-content"]',{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})});await info.attach("axe-open-monitor",{body:JSON.stringify(result,null,2),contentType:"application/json"});expect(result.violations.map(v=>({id:v.id,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([]);
await page.screenshot({path:"docs/ux-ui/screens/shell-monitor-"+theme+"-"+width+".png",animations:"disabled"});await page.keyboard.press("Escape");await page.getByRole("button",{name:"Приложения",exact:true}).click();await expect(page.getByRole("menu")).toBeVisible();await settleAnimations(page);const menu=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run('[role="menu"]',{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})});expect(menu.violations.map(v=>({id:v.id,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([]);
})
for(const theme of ["dark","light"])test("Unconfirmed health remains unavailable/"+theme,async({page})=>{await setup(page,theme);await page.route("**/health",r=>r.fulfill({json:{ok:false,status:"unknown"}}));await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("Нет связи")})
+94
View File
@@ -0,0 +1,94 @@
import { test, expect, type Page } from "@playwright/test"
import { appUserReadSchema } from "../../packages/contracts/src/users"
import { certificatesStateSchema } from "../../packages/contracts/src/certificates"
import { certificateState, server } from "./fixtures/certificates"
import type { BackupItem } from "../../shared/api/backups"
const ids = Array.from({ length: 12 }, (_, i) => i + 1)
const users = ids.map(i => appUserReadSchema.parse({
id: String(i), name: `Пользователь ${i}`, login: `user-${i}`, email: `user${i}@example.test`,
role: "viewer", active: true, avatar: "U", lastSeen: null, sections: [], servers: [], bindings: [],
createdAt: "2026-09-14T00:00:00Z", updatedAt: "2026-09-14T00:00:00Z",
}))
const certificates = certificatesStateSchema.parse(certificateState(ids.map(i => ({
id: `1:*${i}`, rosId: `*${i}`, name: `cert-${i}`, serverId: "1", commonName: `host${i}.example.test`,
sans: [`alt${i}.example.test`], issuedBy: "Test CA", validFrom: "2026-09-01T00:00:00Z", validUntil: "2027-09-01T00:00:00Z",
rawValidFrom: "2026-09-01", rawValidUntil: "2027-09-01",
daysLeft: 300 + i, keySize: 2048, usage: ["server"], trusted: true, status: "valid",
}))))
const backups: BackupItem[] = ids.map(i => ({ id: String(i), serverId: 1, serverName: "Тестовый роутер",
filename: `backup-${i}.backup`, sizeBytes: i === 12 ? 1048576 : i * 1024, createdAt: "2026-09-14T00:00:00Z", kind: i > 6 ? "auto" : "manual", storage: "local" }))
async function setup(page: Page, url: string) {
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
return route.fulfill({ json: path === "/api/users" ? { users }
: path === "/api/certificates/state" ? certificates : path === "/api/backups" ? backups
: path === "/api/certificates/acme-settings" ? { directoryUrl: "https://acme.example.test/directory", defaultZoneId: "", tokenConfigured: false }
: path === "/api/certificates/renew-settings" ? { enabled: false, intervalSec: 3600, renewBeforeDays: 30 }
: path === "/api/backups/schedule" ? { enabled: false, frequency: "daily", hour: 3, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "backup", serverIds: [] }
: path === "/api/backups/storage" ? { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: true }
: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : {} })
})
await page.goto(url)
}
test("Пользователи: 12 строк, сортировка и раскрытие", async ({ page }) => {
await setup(page, "/users")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(12)
await page.getByRole("table").getByRole("button", { name: "Пользователь", exact: true }).click()
await expect(rows.first()).toContainText("Пользователь 1")
await page.getByRole("table").getByRole("button", { name: "Пользователь", exact: true }).click()
await expect(rows.first()).toContainText("Пользователь 12")
await rows.first().click()
await expect(page.getByText("Разделы:", { exact: false }).last()).toBeVisible()
await rows.first().click()
await expect(rows).toHaveCount(12)
})
test("Сертификаты: 12 строк, сортировка и SAN в деталях", async ({ page }) => {
await setup(page, "/certificates")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(12)
await page.getByRole("button", { name: "Сертификат", exact: true }).click()
await page.getByRole("button", { name: "Сертификат", exact: true }).click()
await expect(rows.first()).toContainText("cert-12")
await rows.first().click()
await expect(page.getByText("alt12.example.test", { exact: true })).toBeVisible()
await rows.first().click()
await expect(page.getByText("alt12.example.test", { exact: true })).toBeHidden()
})
test("Бэкапы: 12 строк, числовая сортировка и выбранный файл в подтверждении", async ({ page }) => {
await setup(page, "/backups")
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("backup-12.backup")
await rows.first().getByRole("button", { name: "Удалить backup-12.backup", exact: true }).click()
await expect(page.getByRole("alertdialog")).toContainText("backup-12.backup")
await page.getByRole("button", { name: "Отмена", exact: true }).click()
await expect(rows).toHaveCount(12)
})
test("Бэкапы FilterQuery: тип, изменение значения и сброс", async ({ page }) => {
await setup(page, "/backups")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(12)
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: "авто", exact: true }).click()
await expect(rows).toHaveCount(6)
await expect(page.locator('tr[data-row-id="12"]')).toBeVisible()
await expect(page.locator('tr[data-row-id="1"]')).toHaveCount(0)
await page.getByRole("button", { name: "авто", exact: true }).click()
await page.getByRole("option", { name: "вручную", exact: true }).click()
await expect(rows).toHaveCount(6)
await expect(page.locator('tr[data-row-id="1"]')).toBeVisible()
await expect(page.locator('tr[data-row-id="12"]')).toHaveCount(0)
await page.getByRole("button", { name: "Очистить", exact: true }).click()
await expect(rows).toHaveCount(12)
})
+57
View File
@@ -0,0 +1,57 @@
import {emptyOutbox} from "./fixtures/alerts"
import {test,expect,type Page} from "@playwright/test"
import {emptyAlerts,alertsWithRule,heldOutbox} from "./fixtures/alerts"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,PUT,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization,If-Match"}
async function source(page:Page,value:string){const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill(value);await input.press("Enter");await expect(input).toHaveValue(value);await expect(page.getByText("Доступен",{exact:true})).toBeVisible()}
for(const failed of [false,true])test("Alerts source: ABA discards old primary and target outcomes/"+failed,async({page})=>{
const startedPaths=new Set<string>();let old=true,started=0,completed=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}))
await page.route("**/api/**",async r=>{const url=new URL(r.request().url()),path=url.pathname;if(path==="/api/alerts/outbox")return r.fulfill({headers,json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({headers,json:{required:false}})
if(old&&url.port==="8000"&&["/api/alerts","/api/bgp/sessions","/api/filters/gre-tunnels"].includes(path)){started++;startedPaths.add(path);await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD ALERTS FAILED"}}:{headers,json:path==="/api/alerts"?{...emptyAlerts,telegram:{...emptyAlerts.telegram,chatId:"OLD CHAT"}}:path==="/api/bgp/sessions"?[{serverName:"old",name:"peer"}]:{tunnels:[{name:"old",serverId:"1"}]}})}catch{}finally{completed++};return}
return r.fulfill({headers,json:path==="/api/alerts"?emptyAlerts:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
try{await page.goto("/alerts");await expect.poll(()=>startedPaths.size).toBe(3);await page.goto("/settings");await source(page,"http://localhost:8101");old=false;await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(started);await expect(page.getByText("OLD ALERTS FAILED",{exact:true})).toHaveCount(0);await expect(page.locator('input[value="OLD CHAT"]')).toHaveCount(0);for(const label of ["BGP","GRE"])await expect(page.getByRole("region",{name:"Каталог "+label,exact:true})).toContainText("Получено объектов: 0")
}finally{release()}
})
const configuredTelegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9}
for(const kind of ["save","test"] as const)for(const failed of [false,true])test("Telegram source: ABA retains unknown checkpoint and ignores old receipt/"+kind+"/"+failed,async({page})=>{
await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({headers,json:emptyOutbox});return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,telegram:configuredTelegram}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("http://localhost:8000/api/alerts/telegram"+(kind==="test"?"/test":""),async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD TELEGRAM FAILED"}}:{headers,json:kind==="test"?{ok:true}:{ok:true,telegram:{...configuredTelegram,chatId:"-100draft"}}})}catch{}finally{completed=true}})
const form=()=>page.getByRole("region",{name:"Параметры Telegram",exact:true})
try{await page.goto("/alerts");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(configuredTelegram.chatId);if(kind==="save")await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form().getByRole("button",{name:kind==="save"?"Сохранить в БД":"Отправить тестовое сообщение",exact:true}).click();await expect.poll(()=>started).toBe(true)
await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(form().getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(form()).toContainText("Результат запроса неизвестен");release();await expect.poll(()=>completed).toBe(true);await expect(form().getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await expect(form()).not.toContainText("Параметры Telegram сохранены в БД");await expect(form()).not.toContainText("API подтвердил приём");await expect(form()).not.toContainText("OLD TELEGRAM FAILED");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(configuredTelegram.chatId);expect(calls).toBe(1)
}finally{release()}
})
for(const failed of [false,true])test("Rules source: ABA ignores old save and retains scoped unknown checkpoint/"+failed,async({page})=>{
await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({headers,json:emptyOutbox});return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?alertsWithRule:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("http://localhost:8000/api/alerts/rules",async r=>{calls++;started=true;const body=r.request().postDataJSON();await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD RULES FAILED"}}:{headers,json:{ok:true,revision:"2".repeat(64),groups:body.groups,rules:body.rules.map((row:object)=>({...row,lastFiredAt:null}))}})}catch{}finally{completed=true}})
const toggle=()=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true})
try{await page.goto("/alerts");await expect(toggle()).toBeEnabled();await toggle().click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(toggle()).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText(/Результат сохранения правил неизвестен/)).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(toggle()).toBeChecked();await expect(toggle()).toBeDisabled();await expect(page.getByText("Правила и группы сохранены в БД",{exact:true})).toHaveCount(0);await expect(page.getByText("OLD RULES FAILED",{exact:true})).toHaveCount(0);expect(calls).toBe(1)}finally{release()}
})
for(const failed of [false,true])test("Preview source: late ABA receipt cannot clear Telegram checkpoint/"+failed,async({page})=>{
await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({headers,json:emptyOutbox});return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...alertsWithRule,telegram:configuredTelegram}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("http://localhost:8000/api/alerts/telegram/test",async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD PREVIEW FAILED"}}:{headers,json:{ok:true}})}catch{}finally{completed=true}})
try{await page.goto("/alerts");await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true}).click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await source(page,"http://localhost:8000");await page.goto("/alerts");await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await expect(page.getByRole("dialog")).toContainText("Результат Telegram неизвестен");release();await expect.poll(()=>completed).toBe(true);await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();await expect(page.getByRole("dialog")).not.toContainText("API подтвердил приём");await expect(page.getByRole("dialog")).not.toContainText("OLD PREVIEW FAILED");expect(calls).toBe(1)}finally{release()}
})
for(const failed of [false,true])test("Queue source: ABA ignores old held list or error/"+failed,async({page})=>{
let old=true,started=0,completed=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",async r=>{
const url=new URL(r.request().url()),path=url.pathname
if(path==="/api/alerts/outbox"&&old&&url.port==="8000"){started++;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD QUEUE FAILED"}}:{headers,json:{...emptyOutbox,counts:{...emptyOutbox.counts,total:1,unknown:1},items:[{id:"old",resolutionVersion:0,status:"unknown",ruleName:"OLD HELD",createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T00:00:00Z"}]}})}catch{}finally{completed++};return}
return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
try{await page.goto("/alerts");await expect.poll(()=>started).toBeGreaterThan(0);await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await page.goto("/settings");await source(page,"http://localhost:8101");old=false;await source(page,"http://localhost:8000");await page.goto("/alerts");const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Ожидают: 0");release();await expect.poll(()=>completed).toBe(started);await expect(panel).not.toContainText("OLD QUEUE");await expect(panel).not.toContainText("OLD HELD");await expect(panel).toContainText("Ожидают: 0")}finally{release()}
})
for(const failed of [false,true])test("Queue retry source: ABA retains unknown checkpoint and ignores late receipt/"+failed,async({page})=>{
await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/alerts/outbox"?heldOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("http://localhost:8000/api/alerts/outbox/held-fixture/retry",async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD RETRY FAILED"}}:{headers,json:{ok:true,id:"held-fixture",status:"pending",resolutionVersion:4}})}catch{}finally{completed=true}})
try{await page.goto("/alerts");await page.getByRole("button",{name:"Повторить сообщение Held retry fixture"}).click();const dialog=page.getByRole("alertdialog");await dialog.getByRole("checkbox",{name:"Проверил чат и принимаю риск дубликата"}).check();await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Результат повтора неизвестен");release();await expect.poll(()=>completed).toBe(true);await expect(panel.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeDisabled();await expect(panel).not.toContainText("Сообщение поставлено в очередь");await expect(panel).not.toContainText("OLD RETRY FAILED");expect(calls).toBe(1)}finally{release()}
})
+7
View File
@@ -0,0 +1,7 @@
import {emptyOutbox} from "./fixtures/alerts"
import {test,expect} from "@playwright/test"
import {emptyAlerts} from "./fixtures/alerts"
test("Alerts history distinguishes synthesized monitoring from notification API acceptance and unknown outcome",async({page})=>{
const common={severity:"warning",message:"Fixture history",firedAt:"2026-10-03T00:00:00.000Z"}
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,history:[{...common,id:"live",ruleName:"Live fixture",sent:true,source:"live"},{...common,id:"db",ruleName:"Accepted fixture",sent:true,source:"db"},{...common,id:"unknown",ruleName:"Unknown fixture",sent:false,source:"db"}]}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");for(const text of ["Мониторинг","API принял","Не подтверждено"])await expect(page.getByText(text,{exact:true})).toBeVisible();await expect(page.getByText("✓ отправлено",{exact:true})).toHaveCount(0);await expect(page.getByText("✗ ошибка",{exact:true})).toHaveCount(0)
})
+28
View File
@@ -0,0 +1,28 @@
import {test,expect} from "@playwright/test"
import {alertsWithRule,emptyOutbox} from "./fixtures/alerts"
for(const width of [320,1440])test("Alerts rules priority, accessible filters and no overflow/"+width,async({page})=>{
await page.setViewportSize({width,height:900})
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?alertsWithRule:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
await page.goto("/alerts")
const search=page.getByRole("textbox",{name:"Поиск правил",exact:true})
await expect(search).toBeVisible()
const critical=page.getByRole("button",{name:"Критических: 1",exact:true})
await critical.focus();await page.keyboard.press("Space");await expect(critical).toHaveAttribute("aria-pressed","true")
await search.fill("missing fixture");await expect(page.getByText("Правила не найдены",{exact:true})).toBeVisible()
await search.fill("Fixture rule");await expect(page.getByRole("button",{name:"Изменить правило Fixture rule"})).toBeVisible()
const name=page.getByText("Fixture rule",{exact:true});await expect(name).toBeVisible();expect((await name.boundingBox())!.width).toBeGreaterThan(100)
const position=await search.boundingBox();const catalogs=await page.getByText("Каталоги целей",{exact:true}).boundingBox();const queue=await page.getByRole("region",{name:"Очередь Telegram"}).boundingBox()
expect(position!.y).toBeLessThan(catalogs!.y);expect(position!.y).toBeLessThan(queue!.y)
if(width===320){const telegram=await page.getByRole("region",{name:"Параметры Telegram"}).boundingBox();expect(queue!.y).toBeLessThan(telegram!.y)}else expect(queue!.y).toBeLessThan(900)
await search.scrollIntoViewIfNeeded();await expect(search).toBeInViewport()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/alerts-overview-"+width+".png",fullPage:true})
})
test("Alerts queue remains mounted when delayed primary snapshot becomes known",async({page})=>{
let started=false,calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",async r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts"){started=true;await gate;return r.fulfill({json:alertsWithRule})}if(path==="/api/alerts/outbox")calls++;return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
try{await page.goto("/alerts");await expect.poll(()=>started).toBe(true);await expect(page.getByRole("region",{name:"Очередь Telegram"})).toBeVisible();await expect.poll(()=>calls).toBe(1);release();await expect(page.getByRole("textbox",{name:"Поиск правил",exact:true})).toBeVisible();expect(calls).toBe(1)}finally{release()}
})
+46
View File
@@ -0,0 +1,46 @@
import {test,expect,type Page,type Route} from "@playwright/test"
import {emptyAlerts,emptyOutbox,heldOutbox} from "./fixtures/alerts"
async function setup(page:Page,reply:()=>{status?:number;json:unknown}){
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/alerts/outbox"?reply():{json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts")
}
test("Queue initial failure is unknown, retry confirms zero",async({page})=>{
let fail=true;await setup(page,()=>({json:fail?{}:emptyOutbox}));const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel.getByRole("alert")).toContainText("не подтверждено");await expect(panel).not.toContainText("Ожидают: 0");await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();fail=false;await panel.getByRole("button",{name:"Обновить очередь Telegram"}).click();await expect(panel).toContainText("Ожидают: 0");await expect(panel.getByRole("alert")).toHaveCount(0)
})
test("Queue held states retain confirmed snapshot after failure and fit mobile",async({page})=>{
await page.setViewportSize({width:320,height:1000});let fail=false;const held={...emptyOutbox,counts:{...emptyOutbox.counts,total:4,pending:1,processing:1,unknown:1,unrecognized:1},items:[{id:"a",resolutionVersion:0,status:"unknown",ruleName:"Held fixture",createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T01:00:00Z"},{id:"b",resolutionVersion:0,status:"processing",ruleName:null,createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T02:00:00Z"}]};await setup(page,()=>fail?{status:503,json:{error:"QUEUE UNAVAILABLE"}}:{json:held});const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Held fixture");await expect(panel).toContainText("автоматически не переотправляются");fail=true;await panel.getByRole("button",{name:"Обновить очередь Telegram"}).click();await expect(panel.getByRole("alert")).toContainText("Показан последний успешный снимок");await expect(panel).toContainText("Ожидают: 1");const bounds=await panel.boundingBox();expect(bounds!.x).toBeGreaterThanOrEqual(0);expect(bounds!.x+bounds!.width).toBeLessThanOrEqual(320);await panel.screenshot({path:"test-results/alerts-outbox-320.png"})
})
async function retrySetup(page:Page,write:(r:Route)=>Promise<unknown>,required=false){
if(required)await page.addInitScript(()=>localStorage.setItem("mmapp_token","fixture."+btoa(JSON.stringify({sub:"fixture",exp:Math.floor(Date.now()/1000)+3600,apps:["mm"],permissions:["mm:alerts:read"]}))+".fixture"))
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path.endsWith("/retry"))return write(r);return r.fulfill({json:path==="/api/auth/config"?{required}:path==="/api/alerts"?emptyAlerts:path==="/api/alerts/outbox"?heldOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts")
}
async function openRetry(page:Page){await page.getByRole("button",{name:"Повторить сообщение Held retry fixture"}).click();const dialog=page.getByRole("alertdialog");await expect(dialog.getByRole("button",{name:"Поставить в очередь повторно"})).toBeDisabled();await dialog.getByRole("checkbox",{name:"Проверил чат и принимаю риск дубликата"}).check();return dialog}
test("Manual queue retry: explicit risk, one pending POST excludes concurrent operations and confirms only queue receipt",async({page})=>{
let calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve);await retrySetup(page,async r=>{calls++;expect(r.request().postDataJSON()).toEqual({expectedVersion:3,acknowledgedDuplicateRisk:true});await gate;await r.fulfill({json:{ok:true,id:"held-fixture",status:"pending",resolutionVersion:4}})});
try{const dialog=await openRetry(page);await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click();await expect.poll(()=>calls).toBe(1);await expect(dialog.getByRole("button",{name:"Отмена"})).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true,includeHidden:true})).toBeDisabled();release();await expect(dialog).toHaveCount(0);await expect(page.getByText("Сообщение поставлено в очередь. Отправка и доставка ещё не подтверждены.")).toBeVisible();expect(calls).toBe(1)}finally{release()}
})
test("Manual queue retry: malformed receipt retains dialog and checkpoint across refresh/reload until explicit review",async({page})=>{
let calls=0;await retrySetup(page,async r=>{calls++;return r.fulfill({json:{ok:true}})});const dialog=await openRetry(page);await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click();await expect(dialog.getByRole("alert")).toContainText("не подтверждена");await expect(dialog.getByRole("button",{name:"Поставить в очередь повторно"})).toBeDisabled();await dialog.getByRole("button",{name:"Отмена"}).click();await page.getByRole("button",{name:"Обновить очередь Telegram"}).click();await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeDisabled();await page.reload();const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Результат повтора неизвестен");await expect(panel.getByRole("button",{name:"Результат повтора проверен"})).toBeDisabled();await panel.getByRole("checkbox",{name:"Проверил очередь и чат"}).check();await panel.getByRole("button",{name:"Результат повтора проверен"}).click();await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeEnabled();expect(calls).toBe(1)
})
test("Manual queue retry: read permission cannot enqueue",async({page})=>{
let calls=0;await retrySetup(page,async r=>{calls++;return r.fulfill({status:409,json:{error:"Fixture queue conflict"}})},true);await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeDisabled();expect(calls).toBe(0)
})
test("Manual queue retry: confirmed conflict keeps form without unknown checkpoint",async({page})=>{
await retrySetup(page,r=>r.fulfill({status:409,json:{error:"Fixture queue conflict"}}));const dialog=await openRetry(page);await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click();await expect(dialog.getByRole("alert")).toHaveText("Fixture queue conflict");await expect(dialog.getByRole("checkbox")).toBeChecked();await expect(dialog.getByRole("button",{name:"Поставить в очередь повторно"})).toBeEnabled()
})
test("Manual queue retry: HTTP500 explains unknown outcome inside the dialog and preserves the guard",async({page})=>{
await page.setViewportSize({width:320,height:1000});let calls=0
await retrySetup(page,r=>{calls++;return r.fulfill({status:500,json:{error:"Внутренняя ошибка сервера"}})})
const dialog=await openRetry(page);await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click()
await expect(dialog.getByRole("alert")).toContainText("Результат повтора не подтверждён. Автоматический повтор заблокирован. Проверьте очередь и чат перед новой попыткой.")
await expect(dialog.getByRole("alert")).toContainText("Внутренняя ошибка сервера")
await expect(dialog.getByRole("checkbox")).toBeChecked();await expect(dialog.getByRole("button",{name:"Поставить в очередь повторно"})).toBeDisabled()
const bounds=await dialog.boundingBox();expect(bounds!.x).toBeGreaterThanOrEqual(0);expect(bounds!.x+bounds!.width).toBeLessThanOrEqual(320)
await dialog.screenshot({path:"test-results/alerts-outbox-http500-320.png"})
await dialog.getByRole("button",{name:"Отмена"}).click();await page.reload()
const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Результат повтора неизвестен")
await expect(panel.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeDisabled();expect(calls).toBe(1)
})
test("Manual queue retry: mobile confirmation fits, cancel restores focus without POST",async({page})=>{await page.setViewportSize({width:320,height:1000});let calls=0;await retrySetup(page,r=>{calls++;return r.fulfill({json:{ok:true}})});const dialog=await openRetry(page);const bounds=await dialog.boundingBox();expect(bounds!.x).toBeGreaterThanOrEqual(0);expect(bounds!.x+bounds!.width).toBeLessThanOrEqual(320);await dialog.screenshot({path:"test-results/alerts-outbox-dialog-320.png"});await page.keyboard.press("Escape");await expect(dialog).toHaveCount(0);await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeFocused();expect(calls).toBe(0)})
+6
View File
@@ -0,0 +1,6 @@
import {test,expect} from "@playwright/test"
import {emptyAlerts,emptyOutbox} from "./fixtures/alerts"
for(const width of [320,1440])test("Alerts picker native keyboard selection and one sheet scroll owner/"+width,async({page})=>{
await page.setViewportSize({width,height:900});const servers=Array.from({length:24},(_,i)=>({name:"Fixture server "+i,host:"192.0.2."+(i+1),site:"TEST",country:"RU",type:"exit-node"}))
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,meta:{...emptyAlerts.meta,servers:servers.map(row=>row.name),serversDetail:servers}}:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await page.getByRole("button",{name:"Добавить правило",exact:true}).first().click();const dialog=page.getByRole("dialog");const objects=dialog.getByRole("group",{name:"Объект",exact:true});const card=objects.getByRole("button").filter({hasText:"Fixture server 0"});await card.focus();await page.keyboard.press("Space");await expect(card).toHaveAttribute("aria-pressed","true");const conditions=dialog.getByRole("group",{name:"Условие",exact:true});const condition=conditions.getByRole("button").first();await condition.focus();await page.keyboard.press("Space");await expect(condition).toHaveAttribute("aria-pressed","true");await expect(dialog.getByRole("listbox")).toHaveCount(0);const scrollOwners=await dialog.locator("*").evaluateAll(nodes=>nodes.filter(node=>{const style=getComputedStyle(node);return ["auto","scroll"].includes(style.overflowY)&&node.scrollHeight>node.clientHeight+1}).length);expect(scrollOwners).toBe(1);const save=dialog.getByRole("button",{name:"Сохранить правило",exact:true});await save.scrollIntoViewIfNeeded();await expect(save).toBeInViewport();const bounds=await dialog.boundingBox();expect(bounds!.x).toBeGreaterThanOrEqual(0);expect(bounds!.x+bounds!.width).toBeLessThanOrEqual(width);await dialog.screenshot({path:"test-results/alerts-pickers-"+width+".png"})
})
+6
View File
@@ -0,0 +1,6 @@
import {test,expect} from "@playwright/test"
import {emptyAlerts,emptyOutbox,alertsRule} from "./fixtures/alerts"
test("Unsupported BGP prefix is visible as unavailable, existing disabled rule cannot be enabled and remains editable",async({page})=>{
let writes=0;const rule={...alertsRule,id:"prefix",name:"Legacy prefix",type:"bgp-prefix",groupId:null,enabled:false,target:"любой префикс",targets:["любой префикс"],condition:"был отозван",conditions:["был отозван"]}
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(r.request().method()!=="GET")writes++;return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,rules:[rule]}:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByText("Отслеживание появления и отзыва префиксов пока недоступно. Это правило не проверяется.")).toBeVisible();await expect(page.getByRole("switch",{name:"Включить правило Legacy prefix"})).toBeDisabled();await page.getByRole("button",{name:"Изменить правило Legacy prefix"}).click();const dialog=page.getByRole("dialog");await expect(dialog.getByRole("status")).toContainText("Сохранённое правило не проверяется");await expect(dialog.getByRole("button",{name:/^BGP-префикс/})).toBeDisabled();await dialog.getByRole("button",{name:/^Сервер Доступность/}).click();await expect(dialog.getByRole("status")).toHaveCount(0);expect(writes).toBe(0)
})
+15
View File
@@ -0,0 +1,15 @@
import {emptyOutbox} from "./fixtures/alerts"
import {test,expect,type Page,type Route} from "@playwright/test"
import {alertsWithRule} from "./fixtures/alerts"
const telegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9}
async function setup(page:Page,send:(route:Route)=>Promise<unknown>){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/alerts")return r.fulfill({json:{...alertsWithRule,telegram}});if(path==="/api/alerts/telegram/test")return send(r);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true})).toBeEnabled();await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click()}
test("Rule preview: strict acceptance keeps unsaved form and excludes concurrent save",async({page})=>{
let calls=0,payload:unknown,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
try{await setup(page,async r=>{calls++;payload=r.request().postDataJSON();await gate;return r.fulfill({json:{ok:true}})});const dialog=page.getByRole("dialog");await dialog.getByRole("textbox",{name:"Название правила",exact:true}).fill("Unsaved preview");await dialog.getByRole("button",{name:"Тест в Telegram",exact:true}).click();await expect.poll(()=>calls).toBe(1);await expect(dialog.getByRole("button",{name:"Сохранить изменения",exact:true})).toBeDisabled();await expect(dialog.getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();release();await expect(dialog).toContainText("Telegram API подтвердил приём тестового правила. Проверьте его в чате.");await expect(dialog.getByRole("textbox",{name:"Название правила",exact:true})).toHaveValue("Unsaved preview");expect(payload).toMatchObject({chatId:telegram.chatId,messageThreadId:9,rulePreview:{name:"Unsaved preview",targets:["router-fixture"],conditionLine:"перешёл в offline"}});expect(payload).not.toHaveProperty("token");expect(calls).toBe(1)}finally{release()}
})
test("Rule preview: unknown send protects both preview and Telegram form until explicit review",async({page})=>{
let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:{ok:false}})});const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:"Тест в Telegram",exact:true}).click();await expect(dialog.getByRole("alert")).toContainText("не подтверждена");await expect(dialog.getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();await dialog.getByRole("button",{name:"Отмена",exact:true}).click();const form=page.getByRole("region",{name:"Параметры Telegram",exact:true});await expect(form).toContainText("Результат запроса неизвестен");await expect(form.getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();await page.getByRole("dialog").getByRole("button",{name:"Я проверил Telegram — разрешить новый запрос",exact:true}).click();await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeEnabled();expect(calls).toBe(1)
})
test("Rule preview: Telegram form unknown send cannot be bypassed through editor",async({page})=>{
let calls=0;await setup(page,r=>{calls++;return r.fulfill({status:502,json:{error:"Fixture upstream unknown"}})});await page.getByRole("dialog").getByRole("button",{name:"Отмена",exact:true}).click();const form=page.getByRole("region",{name:"Параметры Telegram",exact:true});await form.getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form.getByRole("alert")).toHaveText("Fixture upstream unknown");await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();expect(calls).toBe(1)
})
+6
View File
@@ -0,0 +1,6 @@
import {emptyOutbox} from "./fixtures/alerts"
import {test,expect,type Page} from "@playwright/test"
const empty={revision:"1".repeat(64),telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
async function setup(page:Page,reply:()=>{status?:number;json:unknown}){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/alerts")return r.fulfill(reply());if(path==="/api/bgp/sessions"||path==="/api/servers")return r.fulfill({json:[]});if(path==="/api/filters/gre-tunnels")return r.fulfill({json:{tunnels:[]}});return r.fulfill({status:503,json:{error:"outside alerts fixture"}})});await page.goto("/alerts")}
for(const malformed of [{},{...empty,rules:null}])test("Alerts reader: malformed live response does not show demo rules or configured Telegram/"+JSON.stringify(malformed),async({page})=>{await setup(page,()=>({json:malformed}));await expect(page.getByText("Данные оповещений не подтверждены сервером",{exact:true})).toBeVisible();await expect(page.getByText("Данные оповещений ещё не получены.",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Добавить правило",exact:true})).toBeDisabled();await expect(page.getByText("Всего: 0",{exact:true})).toHaveCount(0);await expect(page.getByText("Bot Token",{exact:true})).toHaveCount(0)})
test("Alerts reader: confirmed empty is distinct from unknown and background failure retains snapshot",async({page})=>{let fail=false;await setup(page,()=>fail?{status:503,json:{error:"Refresh unavailable"}}:{json:empty});await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByText(/Последние данные получены:/)).toBeVisible();fail=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByText("Refresh unavailable",{exact:true})).toBeVisible();await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByText(/Последние данные получены:/)).toBeVisible()})
+35
View File
@@ -0,0 +1,35 @@
import {emptyOutbox} from "./fixtures/alerts"
import {test,expect,type Page,type Route} from "@playwright/test"
import {alertsWithRule,alertsRule} from "./fixtures/alerts"
async function setup(page:Page,write:(route:Route)=>Promise<unknown>,required=false){
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts")return r.fulfill({json:alertsWithRule});if(path==="/api/alerts/rules")return write(r);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByText("Всего: 1",{exact:true})).toBeVisible()
}
function receipt(r:Route){const body=r.request().postDataJSON();return {ok:true,revision:"2".repeat(64),groups:body.groups,rules:body.rules.map((rule:object)=>({...rule,lastFiredAt:null}))}}
const toggle=(page:Page)=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true})
test("Rules: pending toggle keeps confirmed state, excludes writes and commits only strict receipt",async({page})=>{
let calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
try{await setup(page,async r=>{calls++;await gate;return r.fulfill({json:receipt(r)})});await toggle(page).click();await expect.poll(()=>calls).toBe(1);await expect(toggle(page)).toBeChecked();await expect(toggle(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeDisabled();for(const button of await page.getByRole("button",{name:"Добавить правило",exact:true}).all())await expect(button).toBeDisabled();release();await expect(toggle(page)).not.toBeChecked();await expect(page.getByText("Правила и группы сохранены в БД",{exact:true})).toBeVisible();expect(calls).toBe(1)}finally{release()}
})
test("Rules: failed edit keeps form and recovery settings for explicit retry",async({page})=>{
let calls=0,payload:unknown,reject=true;await setup(page,r=>{calls++;payload=r.request().postDataJSON();return r.fulfill(reject?{status:409,json:{error:"Fixture conflict"}}:{json:receipt(r)})});await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();const dialog=page.getByRole("dialog"),name=dialog.getByRole("textbox",{name:"Название правила",exact:true});await name.fill("Changed rule");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog.getByRole("alert")).toHaveText("Fixture conflict");await expect(name).toHaveValue("Changed rule");await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect((payload as {rules:{recoveryMode:string;recoveryStabilitySec:number}[]}).rules[0]).toMatchObject({recoveryMode:"conditional",recoveryStabilitySec:17});reject=false;await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog).not.toBeVisible();await expect(page.getByText("Changed rule",{exact:true})).toBeVisible();expect(calls).toBe(2)
})
test("Rules: malformed delete preserves row and unknown guard across refresh/reload",async({page})=>{
let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:{ok:false}})});await page.getByRole("button",{name:"Удалить правило Fixture rule",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.locator("#main-content").getByRole("alert")).toContainText("не подтверждено");await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();await expect(toggle(page)).toBeDisabled();await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(toggle(page)).toBeDisabled();await page.reload();await expect(page.getByText(/Результат сохранения правил неизвестен/)).toBeVisible();await expect(toggle(page)).toBeDisabled();expect(calls).toBe(1)
})
test("Groups: typing is draft, failed save retains it and successful receipt updates group",async({page})=>{
let calls=0,reject=true,payload:unknown;await setup(page,r=>{calls++;payload=r.request().postDataJSON();return r.fulfill(reject?{status:409,json:{error:"Fixture group conflict"}}:{json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();const group=page.getByRole("region",{name:"Группа Fixture group",exact:true});await group.getByRole("textbox",{name:"Имя группы",exact:true}).fill("New draft group");expect(calls).toBe(0);await group.getByRole("combobox",{name:"Режим группы",exact:true}).selectOption("all");await group.getByRole("button",{name:"Сохранить группу",exact:true}).click();await expect(page.locator("#main-content").getByRole("alert")).toContainText("Fixture group conflict");await expect(group.getByRole("textbox")).toHaveValue("New draft group");expect((payload as {groups:object[]}).groups[0]).toMatchObject({name:"New draft group",combineMode:"all"});reject=false;await group.getByRole("button",{name:"Сохранить группу",exact:true}).click();await expect(page.getByRole("region",{name:"Группа New draft group",exact:true})).toBeVisible();expect(calls).toBe(2)
})
test("Rules: read permission keeps all mutation entry points disabled",async({page})=>{
const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:alerts:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:receipt(r)})},true);await expect(toggle(page)).toBeDisabled();await expect(page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Удалить правило Fixture rule",exact:true})).toBeDisabled();for(const button of await page.getByRole("button",{name:"Добавить правило",exact:true}).all())await expect(button).toBeDisabled();await page.getByRole("button",{name:/Пресеты и группы/}).click();await expect(page.getByRole("button",{name:"Группа",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Добавить к списку",exact:true}).first()).toBeDisabled();await expect(page.getByRole("region",{name:"Группа Fixture group",exact:true}).getByRole("textbox")).toBeDisabled();expect(calls).toBe(0)
})
test("Rules: unknown edit keeps draft through explicit review and confirmed retry",async({page})=>{let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:calls===1?{ok:false}:receipt(r)})});await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();const dialog=page.getByRole("dialog"),input=dialog.getByRole("textbox",{name:"Название правила",exact:true});await input.fill("Changed unknown draft");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog.getByRole("alert")).toContainText("не подтверждено");await expect(input).toHaveValue("Changed unknown draft");await expect(dialog.getByRole("button",{name:"Сохранить изменения",exact:true})).toBeDisabled();await dialog.getByRole("button",{name:"Я проверил правила — разрешить новый запрос",exact:true}).click();await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog).not.toBeVisible();await expect(page.getByText("Changed unknown draft",{exact:true})).toBeVisible();expect(calls).toBe(2)})
test("Rules presets: no confirmed RTT targets cannot clear existing rules",async({page})=>{let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();await page.getByRole("button",{name:"Заменить все",exact:true}).nth(1).click();await page.getByRole("alertdialog").getByRole("button",{name:"Заменить",exact:true}).click();await expect(page.locator("#main-content").getByRole("alert")).toHaveText("Для пресета нет подтверждённых объектов. Список правил сохранён.");await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect(calls).toBe(0)})
test("Groups: confirmed delete removes group while retaining its rules without membership",async({page})=>{let payload:{groups:unknown[];rules:{groupId:string|null}[]}|undefined;await setup(page,r=>{payload=r.request().postDataJSON();return r.fulfill({json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();await page.getByRole("region",{name:"Группа Fixture group",exact:true}).getByRole("button",{name:"Удалить группу",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.getByRole("region",{name:"Группа Fixture group",exact:true})).toHaveCount(0);await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect(payload?.groups).toEqual([]);expect(payload?.rules).toHaveLength(1);expect(payload?.rules[0]?.groupId).toBeNull()})
test("Rules revision: conflict refresh preserves draft and sends new revision with concurrent rule",async({page})=>{
const latest="3".repeat(64),headers:(string|undefined)[]=[];let calls=0,payload:{rules:{id:string}[]}|undefined
await setup(page,r=>{calls++;headers.push(r.request().headers()["if-match"]);payload=r.request().postDataJSON();return r.fulfill(calls===1?{status:412,json:{error:"Правила изменились на сервере"}}:{json:receipt(r)})})
await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();const dialog=page.getByRole("dialog"),input=dialog.getByRole("textbox",{name:"Название правила",exact:true});await input.fill("Conflict draft");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog.getByRole("alert")).toHaveText("Правила изменились на сервере")
await page.route("**/api/alerts",r=>r.fulfill({json:{...alertsWithRule,revision:latest,rules:[alertsRule,{...alertsRule,id:"concurrent-rule",name:"Concurrent rule"}]}}));await dialog.getByRole("button",{name:"Обновить снимок — сохранить черновик",exact:true}).click();await expect(page.getByText("Всего: 2",{exact:true})).toBeVisible();await expect(input).toHaveValue("Conflict draft");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog).not.toBeVisible();await expect(page.getByText("Concurrent rule",{exact:true})).toBeVisible();expect(headers).toEqual(['"'+"1".repeat(64)+'"','"'+latest+'"']);expect(payload?.rules.map(rule=>rule.id)).toEqual(["rule-fixture","concurrent-rule"]);expect(calls).toBe(2)
})
+52
View File
@@ -0,0 +1,52 @@
import {test,expect,type Page} from "@playwright/test"
import {emptyAlerts,emptyOutbox} from "./fixtures/alerts"
import type {AlertTargetIssue} from "@mmapp/contracts/alerts"
async function setup(page:Page,reply:()=>unknown){
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname
if(path==="/api/auth/config")return r.fulfill({json:{required:false}})
if(path==="/api/alerts")return r.fulfill({json:reply()})
if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox})
if(path==="/api/servers"||path==="/api/bgp/sessions")return r.fulfill({json:[]})
if(path==="/api/filters/gre-tunnels")return r.fulfill({json:{tunnels:[]}})
return r.fulfill({status:503,json:{error:"outside diagnostic fixture"}})
})
await page.goto("/alerts")
}
for(const width of[1440,320])test(`Alerts targets: ambiguity is explained and grouped at ${width}`,async({page})=>{
await page.setViewportSize({width,height:1000})
const issues:AlertTargetIssue[]=[{kind:"server",target:"CHR",reason:"ambiguous"},{kind:"traffic",target:"CHR",reason:"ambiguous"}]
let current=issues
await setup(page,()=>({...emptyAlerts,meta:{...emptyAlerts.meta,targetIssues:current}}))
const warning=page.getByRole("status",{name:"Проблемы целей оповещений"})
await expect(warning).toBeVisible();await expect(warning.getByText("CHR",{exact:true})).toHaveCount(1)
await expect(warning).toContainText("Несколько включённых объектов")
await expect(warning).toContainText("обновите цели существующих правил")
await expect(warning.getByRole("link",{name:"Проверить серверы"})).toHaveAttribute("href","/servers")
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
current=[];await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(warning).toHaveCount(0)
})
test("Alerts targets: disabled source and probe explain the next action",async({page})=>{
await setup(page,()=>({...emptyAlerts,meta:{...emptyAlerts.meta,targetIssues:[{kind:"traffic",target:"router",reason:"disabled-source"},{kind:"probe",target:"probe → target",reason:"disabled-object"}]}}))
const warning=page.getByRole("status",{name:"Проблемы целей оповещений"})
await expect(warning).toContainText("Сбор данных этого типа выключен")
await expect(warning).toContainText("Объект или его сервер выключен")
await expect(warning.getByRole("link",{name:"Проверить сбор данных"})).toHaveAttribute("href","/data-collection")
await expect(warning.getByRole("link",{name:"Проверить пробы"})).toHaveAttribute("href","/uptime")
})
test("Alerts targets: older valid API does not invent diagnostics",async({page})=>{
await setup(page,()=>emptyAlerts)
await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible()
await expect(page.getByRole("status",{name:"Проблемы целей оповещений"})).toHaveCount(0)
})
test("Alerts targets: many long labels remain compact and keyboard accessible on phone",async({page})=>{
await page.setViewportSize({width:320,height:1000})
const targets=Array.from({length:6},(_,index)=>`target-${index}-${"x".repeat(180)}`)
await setup(page,()=>({...emptyAlerts,meta:{...emptyAlerts.meta,targetIssues:targets.map(target=>({kind:"server",target,reason:"ambiguous"}))}}))
const warning=page.getByRole("status",{name:"Проблемы целей оповещений"})
await expect(warning.getByText(targets[0],{exact:true})).toBeHidden()
const summary=warning.locator("summary");await summary.focus();await page.keyboard.press("Enter")
await expect(warning.getByText(targets[0],{exact:true})).toBeVisible()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await page.keyboard.press("Enter");await expect(warning.getByText(targets[0],{exact:true})).toBeHidden()
})
+31
View File
@@ -0,0 +1,31 @@
import {emptyOutbox} from "./fixtures/alerts"
import {test,expect,type Page,type Route} from "@playwright/test"
import {emptyAlerts} from "./fixtures/alerts"
async function setup(page:Page,secondary:(route:Route,path:string)=>Promise<unknown>,required=false){
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts")return r.fulfill({json:emptyAlerts});return secondary(r,path)})
await page.goto("/alerts")
}
test("Alerts catalogs: delayed BGP does not block primary or GRE",async({page})=>{
let started=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
try{await setup(page,async(r,path)=>{if(path==="/api/bgp/sessions"){started=true;await gate;try{await r.fulfill({json:[]})}catch{};return}return r.fulfill({json:path==="/api/servers"?[]:{tunnels:[],failures:[]}})})
await expect.poll(()=>started).toBe(true);await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeEnabled();await expect(page.getByRole("region",{name:"Каталог GRE",exact:true})).toContainText("Получено объектов: 0");await expect(page.getByRole("region",{name:"Каталог BGP",exact:true})).toContainText("Обновление…");release();await expect(page.getByRole("region",{name:"Каталог BGP",exact:true})).toContainText("Получено объектов: 0")
}finally{release()}
})
test("Alerts catalogs: partial GRE and malformed refresh preserve last complete snapshot",async({page})=>{
let response:unknown={tunnels:[{name:"gre-fixture",serverId:"1"}],failures:[]};let greCalls=0,bgpCalls=0
await setup(page,(r,path)=>{if(path==="/api/filters/gre-tunnels"){greCalls++;return r.fulfill({json:response})}if(path==="/api/bgp/sessions"){bgpCalls++;return r.fulfill({json:[]})}return r.fulfill({json:[]})})
const gre=page.getByRole("region",{name:"Каталог GRE",exact:true});await expect(gre).toContainText("Получено объектов: 1");const time=await gre.getByRole("status").innerText();const previousBgpCalls=bgpCalls,previousGreCalls=greCalls
response={tunnels:[],failures:[{serverId:"2",serverName:"router-fixture",error:"offline"}]};await gre.getByRole("button").click();await expect(gre.getByRole("alert")).toContainText("Неполный каталог GRE");await expect(gre.getByRole("status")).toHaveText(time);expect(bgpCalls).toBe(previousBgpCalls)
response={tunnels:null};await gre.getByRole("button").click();await expect(gre.getByRole("alert")).toContainText("Каталог GRE для оповещений не подтверждён");await expect(gre.getByRole("status")).toHaveText(time);expect(greCalls).toBe(previousGreCalls+2)
})
test("Alerts catalogs: initial partial GRE remains unknown",async({page})=>{await setup(page,(r,path)=>r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[{serverId:"1",serverName:"router-fixture",error:"offline"}]}:[]}));const gre=page.getByRole("region",{name:"Каталог GRE",exact:true});await expect(gre.getByRole("alert")).toContainText("Неполный каталог GRE");await expect(gre.getByRole("status")).toHaveText("Данные ещё не получены.");await expect(gre).not.toContainText("Получено объектов: 0")})
test("Alerts catalogs: alerts read permission does not grant network catalog access",async({page})=>{
const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:alerts:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture"
await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token)
const calls:string[]=[];await setup(page,(r,path)=>{if(["/api/bgp/sessions","/api/filters/gre-tunnels","/api/sidebar-counts"].includes(path))calls.push(path);return r.fulfill({json:[]})},true)
await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible()
for(const label of ["BGP","Серверы","GRE"])await expect(page.getByRole("region",{name:"Каталог "+label,exact:true}).getByRole("alert")).toHaveText("Нет доступа к каталогу")
const baseline=[...calls];for(const label of ["BGP","Серверы","GRE"])await page.getByRole("button",{name:"Обновить каталог "+label,exact:true}).click();expect(calls).toEqual(baseline);expect(calls).toEqual([])
})
+51
View File
@@ -0,0 +1,51 @@
import {emptyOutbox} from "./fixtures/alerts"
import {test,expect,type Page,type Route} from "@playwright/test"
import {emptyAlerts} from "./fixtures/alerts"
const telegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9}
async function setup(page:Page,handler:(route:Route,path:string)=>Promise<unknown>,required=false){
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts"||path.startsWith("/api/alerts/telegram"))return handler(r,path);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})})
await page.goto("/alerts");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(telegram.chatId)
}
const form=(page:Page)=>page.getByRole("region",{name:"Параметры Telegram",exact:true})
test("Telegram: dirty fields survive refresh and selective save leaves refreshed fields untouched",async({page})=>{
let current={...telegram},payload:unknown,calls=0
await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram:current}});calls++;payload=r.request().postDataJSON();current={...current,...payload as object};return r.fulfill({json:{ok:true,telegram:current}})})
await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");current={...current,chatId:"-100external",messageThreadId:17};await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true})).toHaveValue("17");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft")
await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByText("Параметры Telegram сохранены в БД",{exact:true})).toBeVisible();expect(payload).toEqual({chatId:"-100draft"});expect(calls).toBe(1);await expect(form(page).getByRole("button",{name:"Сохранить в БД",exact:true})).toBeDisabled();await expect(form(page)).not.toContainText("Подключён")
})
test("Telegram: invalid thread is preserved and no PUT or POST is sent",async({page})=>{
let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:true}})})
const input=page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true});for(const value of ["12wrong","1.5","0","2147483648"]){await input.fill(value);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("ID темы");await expect(input).toHaveValue(value);await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("ID темы")};expect(calls).toBe(0)
})
test("Telegram: malformed save remains unknown across refresh and reload without secret storage",async({page})=>{
let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:false}})})
await page.locator("#alerts-bot-token").fill("fixture-secret");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("не подтверждено");await expect(page.locator("#alerts-bot-token")).toHaveValue("fixture-secret");await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.locator("#alerts-bot-token")).toHaveValue("fixture-secret");expect(await page.evaluate(()=>JSON.stringify(sessionStorage))).not.toContain("fixture-secret");await page.reload();await expect(form(page)).toContainText("Результат запроса неизвестен");await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();expect(calls).toBe(1)
})
test("Telegram: confirmed rejection preserves draft and allows corrected save",async({page})=>{
let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({status:403,json:{error:"Fixture denied"}})})
await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toHaveText("Fixture denied");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft");await expect(form(page).getByRole("button",{name:"Сохранить в БД",exact:true})).toBeEnabled();await expect(form(page)).not.toContainText("Результат запроса неизвестен");expect(calls).toBe(1)
})
test("Telegram: one pending write excludes save/test and refresh until strict receipt",async({page})=>{
let calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
try{await setup(page,async(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;await gate;return r.fulfill({json:{ok:true,telegram:{...telegram,chatId:"-100draft"}}})});await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect.poll(()=>calls).toBe(1);await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeDisabled();await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toBeDisabled();release();await expect(form(page).getByText("Параметры Telegram сохранены в БД",{exact:true})).toBeVisible();expect(calls).toBe(1)}finally{release()}
})
test("Telegram: strict test receipt acknowledges API acceptance without claiming delivery",async({page})=>{
let calls=0,payload:unknown;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;payload=r.request().postDataJSON();return r.fulfill({json:{ok:true}})})
await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page)).toContainText("Telegram API подтвердил приём тестового сообщения. Проверьте его в чате.");expect(payload).toEqual({chatId:telegram.chatId,messageThreadId:9});expect(calls).toBe(1);await expect(form(page)).not.toContainText("Сообщение отправлено")
})
test("Telegram: alerts read does not grant config save or test",async({page})=>{
const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:alerts:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token)
let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:true}})},true);await expect(form(page)).toContainText("Нет права изменять оповещения");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toBeDisabled();await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();expect(calls).toBe(0)
})
test("Telegram: compact form remains within 320px viewport",async({page})=>{await page.setViewportSize({width:320,height:900});await setup(page,r=>r.fulfill({json:{...emptyAlerts,telegram}}));await form(page).scrollIntoViewIfNeeded();const box=await form(page).boundingBox();expect(box).not.toBeNull();expect(box!.x).toBeGreaterThanOrEqual(0);expect(box!.x+box!.width).toBeLessThanOrEqual(320);await page.screenshot({path:"test-results/alerts-telegram-320.png",fullPage:true})})
test("Telegram: clearing thread is explicit and test cannot silently use the saved topic",async({page})=>{
let payload:unknown,calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;payload=r.request().postDataJSON();return r.fulfill({json:{ok:true,telegram:{...telegram,messageThreadId:null}}})})
await page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true}).fill("");await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("Сначала сохраните очистку ID темы");expect(calls).toBe(0);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page)).toContainText("Параметры Telegram сохранены в БД");expect(payload).toEqual({messageThreadId:null});expect(calls).toBe(1)
})
test("Telegram: late overview GET cannot replace a confirmed save",async({page})=>{
let delayed=false,started=false,completed=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
try{await setup(page,async(r,path)=>{if(path==="/api/alerts"){if(delayed){started=true;await gate;try{await r.fulfill({json:{...emptyAlerts,telegram:{...telegram,chatId:"OLD CHAT"}}})}catch{}finally{completed=true};return}return r.fulfill({json:{...emptyAlerts,telegram}})}return r.fulfill({json:{ok:true,telegram:{...telegram,chatId:"-100draft"}}})});await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");delayed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>started).toBe(true);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page)).toContainText("Параметры Telegram сохранены в БД");release();await expect.poll(()=>completed).toBe(true);await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft")}finally{release()}
})
+28
View File
@@ -0,0 +1,28 @@
import {test,expect} from '@playwright/test'
const token=(permissions:string[])=>[Buffer.from('{}').toString('base64url'),Buffer.from(JSON.stringify({sub:'own199',name:'Own199',email:'own199@example.test',apps:['mm'],permissions,exp:Math.floor(Date.now()/1000)+600})).toString('base64url'),'fixture'].join('.')
for(const width of [1440,320]){
for(const [name,next,permissions,expected]of [
['query','/servers?server=42&tab=details',['mm:servers:read'],'/servers?server=42&tab=details'],
['denied','/settings',['mm:servers:read'],'/servers'],
['external','//outside.invalid',['mm:servers:read'],'/servers'],
['loop','/auth/callback?next=/servers',['mm:servers:read'],'/servers'],
] as const)test(`SSO return ${name} ${width}`,async({page})=>{
await page.setViewportSize({width,height:1000})
await page.route('**/api/**',r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==='/api/auth/config'?{required:true,portal_url:'https://portal.example.test'}:path==='/api/servers'?[]:path==='/api/v1/app-switcher'?{menuLabel:'Приложения',apps:[]}:{}})})
await page.goto('/auth/callback?next='+encodeURIComponent(next)+'#access_token='+token([...permissions]))
await expect.poll(()=>new URL(page.url()).pathname+new URL(page.url()).search).toBe(expected)
expect(new URL(page.url()).hash).toBe('')
await expect(page.getByRole('button',{name:'Добавить сервер',exact:true})).toBeVisible()
})
test(`SSO handoff captures query ${width}`,async({page})=>{
await page.setViewportSize({width,height:1000})
await page.route('**/api/auth/config',r=>r.fulfill({json:{required:true,portal_url:'https://portal.example.test/'}}))
await page.route('https://portal.example.test/**',r=>r.fulfill({contentType:'text/html',body:'<p>Own Portal fixture</p>'}))
await page.goto('/servers?server=42&tab=details')
await expect.poll(()=>new URL(page.url()).hostname).toBe('portal.example.test')
const callback=new URL(new URL(page.url()).searchParams.get('return_to')!)
expect(callback.origin).toBe('http://127.0.0.1:3100')
expect(callback.pathname).toBe('/auth/callback')
expect(callback.searchParams.get('next')).toBe('/servers?server=42&tab=details')
})
}
+105
View File
@@ -0,0 +1,105 @@
import { test, expect } from '@playwright/test'
// This file runs only via playwright.backend.config.ts; ordinary E2E lock the backend URL.
for (const status of [200, 500]) {
test(`смена backend: поздний Ping ${status} не меняет новый источник`, async ({ page }) => {
let release!: () => void
const pending = new Promise<void>(resolve => { release = resolve })
const posts: string[] = []
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
const isNew = url.port === '8999'
if (request.method() === 'POST') {
posts.push(url.origin + url.pathname)
if (!isNew && url.pathname === '/api/uptime/probes/collect-group') {
await pending
return route.fulfill({ status, json: status === 200 ? {} : { error: 'Ошибка прежнего backend' } })
}
}
const servers = [{ id: 1, name: isNew ? 'Новый источник' : 'Старый источник', host: '192.0.2.1', type: 'exit-node', enabled: true, country: 'RU', site: 'TEST', status: 'online', wanUplinks: [] }]
const probes = [{ id: 'p1', srcServerId: '1', name: isNew ? 'Новая проверка' : 'Старая проверка', target: '192.0.2.10', filter: 'main', rtt: 10, loss: 0, status: 'up', series: [10], enabled: true, showOnDashboard: false }]
const json = url.pathname === '/api/auth/config' ? { required: false }
: url.pathname === '/api/servers' ? servers
: url.pathname === '/api/uptime/overview' ? { probes, resources: [] }
: url.pathname === '/api/uptime/speed-probes' ? { probes: [] }
: url.pathname === '/api/uptime/speed-test/runs' ? { runs: [] }
: url.pathname === '/api/evobgp/settings' ? { enabled: false }
: {}
await route.fulfill({ json })
})
await page.goto('/uptime?probe=p1')
const context = page.getByRole('status', { name: 'Проверка по ссылке' })
await expect(context).toContainText('Старая проверка')
const started = page.waitForRequest(r => r.url().endsWith('/collect-group'))
await page.getByRole('button', { name: 'Обновить', exact: true }).click()
await started
await page.locator('a[href="/settings"]').first().click()
const backend = page.getByPlaceholder('http://localhost:8000', { exact: true })
await backend.fill('http://127.0.0.1:8999')
await backend.press('Enter')
await page.goBack()
await expect(context).toContainText('Новая проверка')
const response = page.waitForResponse(r => r.url().includes(':8000/') && r.url().endsWith('/collect-group'))
release()
await response
await expect(context).toContainText('Новая проверка')
await expect(page.getByText('Ошибка прежнего backend', { exact: true })).toHaveCount(0)
await page.getByRole('button', { name: 'Обновить', exact: true }).click()
await expect.poll(() => posts).toEqual([
'http://localhost:8000/api/uptime/probes/collect-group',
'http://127.0.0.1:8999/api/uptime/probes/collect-group',
])
await expect(page.getByRole('button', { name: 'Новая проба', exact: true })).toBeEnabled()
})
}
test('смена backend: поздний список трафика не заменяет новый источник', async ({ page }) => {
let hold = false
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const refreshOrigins: string[] = []
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
const isNew = url.port === '8999'
const server = { id: '1', name: isNew ? 'Новый трафик' : 'Старый трафик', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 4, txPeak: 5, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] }
if (url.pathname === '/api/traffic/servers') {
if (hold) {
refreshOrigins.push(url.origin)
if (!isNew) await gate
}
return route.fulfill({ json: { servers: [server] } })
}
const json = url.pathname === '/api/auth/config' ? { required: false }
: url.pathname === '/api/traffic/servers/1' ? { server }
: url.pathname.endsWith('/interfaces') || url.pathname === '/api/traffic/bound-interfaces' ? { interfaces: [] }
: url.pathname === '/api/traffic/users' ? { users: [] }
: url.pathname === '/api/servers' ? [] : {}
if (url.pathname.endsWith('/live')) return route.fulfill({ status: 503, json: {} })
return route.fulfill({ json })
})
await page.goto('/traffic')
await expect(page.getByRole('heading', { name: 'Старый трафик', exact: true })).toBeVisible()
hold = true
const started = page.waitForRequest(r => r.url().includes(':8000/api/traffic/servers?'))
await page.getByRole('button', { name: 'Обновить', exact: true }).click()
await started
await page.locator('a[href="/settings"]').first().click()
const backend = page.getByPlaceholder('http://localhost:8000', { exact: true })
await backend.fill('http://127.0.0.1:8999')
await backend.press('Enter')
await page.goBack()
await expect(page.getByRole('heading', { name: 'Новый трафик', exact: true })).toBeVisible()
const late = page.waitForResponse(r => r.url().includes(':8000/api/traffic/servers?'))
release()
await late
await expect(page.getByRole('heading', { name: 'Новый трафик', exact: true })).toBeVisible()
await expect(page.getByRole('button').filter({ hasText: 'Старый трафик' })).toHaveCount(0)
const beforeRefresh = refreshOrigins.length
const fresh = page.waitForResponse(r => r.url().includes(':8999/api/traffic/servers?'))
await page.getByRole('button', { name: 'Обновить', exact: true }).click()
await fresh
expect(refreshOrigins.slice(beforeRefresh)).toEqual(['http://127.0.0.1:8999'])
})
+94
View File
@@ -0,0 +1,94 @@
import { test, expect } from "@playwright/test"
import { openBackup, backupJob, backupAck } from "./fixtures/backup-job"
import { backup, setupBackups } from "./fixtures/backups"
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: позднее чтение/" + change + "/" + fails, async ({ page }) => {
await setupBackups(page, headers)
let started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups", async r => {
if (new URL(r.request().url()).port !== "8000") return r.fallback()
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРАЯ ОШИБКА БЭКАПА" } } : { headers, json: [{ ...backup, filename: "СТАРЫЙ БЭКАП.rsc" }] }) } catch {} finally { completed = true }
})
await page.goto("/backups"); await expect.poll(() => started).toBe(true); await page.goto("/settings")
if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") }
else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.goto("/backups"); release(); await expect.poll(() => completed).toBe(true)
if (change === "backend") await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
else { await expect(page.getByText("Демонстрационные данные. Операции и сохранение недоступны.", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Новый бэкап", exact: true }).first()).toBeDisabled() }
await expect(page.getByText("СТАРЫЙ БЭКАП.rsc", { exact: true })).toHaveCount(0)
await expect(page.getByText(/СТАРАЯ ОШИБКА БЭКАПА/)).toHaveCount(0)
})
for (const kind of ["schedule", "storage"]) for (const fails of [false, true]) test("Бэкапы: позднее сохранение/" + kind + "/" + fails, async ({ page }) => {
await setupBackups(page, headers)
let started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups/" + kind, async r => {
if (r.request().method() !== "PUT") return r.fallback()
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРОЕ СОХРАНЕНИЕ БЭКАПА" } } : { headers, json: { ...r.request().postDataJSON(), secretConfigured: false, serverIds: ["1"] } }) } catch {} finally { completed = true }
})
await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click()
const label = kind === "schedule" ? "Сохранить расписание" : "Сохранить хранилище"
await page.getByRole("button", { name: label, exact: true }).click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings"); const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter")
await page.goto("/backups"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
await expect(page.getByText(/СТАРОЕ СОХРАНЕНИЕ БЭКАПА/)).toHaveCount(0)
await expect(page.getByText(kind === "schedule" ? "Расписание сохранено" : "Хранилище сохранено", { exact: true })).toHaveCount(0)
})
for (const phase of ["POST", "poll"]) for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: поздний job/" + phase + "/" + change + "/" + fails, async ({ page }) => {
await setupBackups(page, headers)
let id = "", started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups/create", async r => {
id = r.request().postDataJSON().requestId
if (phase !== "POST") return r.fulfill({ headers, status: 202, json: backupAck(id) })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ JOB ОТКАЗ" } } : { headers, status: 202, json: backupAck(id) }) } catch {} finally { completed = true }
})
await page.route("**/api/backups/jobs/*", async r => {
if (phase !== "poll") return r.fulfill({ headers, json: backupJob(id) })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ JOB ОТКАЗ" } } : { headers, json: backupJob(id) }) } catch {} finally { completed = true }
})
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings")
if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") }
else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.goto("/backups"); release(); await expect.poll(() => completed).toBe(true)
if (change === "backend") await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
else await expect(page.getByText("Демонстрационные данные. Операции и сохранение недоступны.", { exact: true })).toBeVisible()
await expect(page.getByText(/СТАРЫЙ JOB ОТКАЗ/)).toHaveCount(0)
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Задача резервного копирования", exact: true })).toHaveCount(0)
})
for (const operation of ["delete", "restore", "download"]) for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: поздняя операция/" + operation + "/" + change + "/" + fails, async ({ page }) => {
await setupBackups(page, headers)
let started = false, completed = false, downloads = 0, release!: () => void; const gate = new Promise<void>(r => release = r)
page.on("download", () => downloads++)
const suffix = operation === "delete" ? "" : "/" + operation
await page.route("**/api/backups/backup-1" + suffix, async r => {
started = true; await gate
try {
if (fails) await r.fulfill({ headers, status: 503, json: { error: "СТАРАЯ ОПЕРАЦИЯ ОТКАЗАЛА" } })
else if (operation === "delete") await r.fulfill({ headers, status: 204 })
else if (operation === "restore") await r.fulfill({ headers, json: { backupId: backup.id, serverId: 1, filename: backup.filename, serverName: "СТАРЫЙ РОУТЕР" } })
else await r.fulfill({ headers, contentType: "text/plain", body: "old config" })
} catch {} finally { completed = true }
})
await page.goto("/backups")
const label = operation === "delete" ? "Удалить" : operation === "restore" ? "Восстановить" : "Скачать"
await page.getByRole("button", { name: label + " " + backup.filename, exact: true }).click()
if (operation !== "download") await page.getByRole("alertdialog").getByRole("button", { name: label, exact: true }).click()
await expect.poll(() => started).toBe(true); await page.goto("/settings")
if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") }
else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.goto("/backups"); release(); await expect.poll(() => completed).toBe(true)
if (change === "backend") await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
else await expect(page.getByText("Демонстрационные данные. Операции и сохранение недоступны.", { exact: true })).toBeVisible()
await expect(page.getByText(/СТАРАЯ ОПЕРАЦИЯ ОТКАЗАЛА|СТАРЫЙ РОУТЕР/)).toHaveCount(0)
await expect(page.getByText("API подтвердил удаление резервной копии", { exact: true })).toHaveCount(0)
await expect(page.getByRole("alertdialog")).toHaveCount(0); expect(downloads).toBe(0)
})
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test"
import { backup, setupBackups } from "./fixtures/backups"
for (const invalid of ["API", "malformed", "duplicate"]) test("Бэкапы: первоначальный отказ " + invalid + " и повтор", async ({ page }) => {
await setupBackups(page); let fail = true
await page.route("**/api/backups", r => r.fulfill(fail ? invalid === "API" ? { status: 503, json: { error: "История отказала" } } : { json: invalid === "duplicate" ? [backup, backup] : {} } : { json: [backup] }))
await page.goto("/backups")
await expect(page.getByRole("alert", { name: "Данные резервных копий: backups" })).toContainText("Данные пока неизвестны")
await expect(page.getByText("Нет бэкапов", { exact: true })).toHaveCount(0)
fail = false; await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
})
test("Бэкапы: подтверждённая пустая история", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [] })); await page.goto("/backups")
await expect(page.getByText("Нет бэкапов", { exact: true })).toBeVisible()
})
for (const resource of ["servers", "backups/schedule", "backups/storage"]) test("Бэкапы: отказ зависимости " + resource + " сохраняет историю", async ({ page }) => {
await setupBackups(page); await page.route("**/api/" + resource, r => r.fulfill({ status: 503, json: { error: "Зависимость отказала" } }))
await page.goto("/backups"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
await expect(page.getByText("0 Б", { exact: true })).toBeVisible()
if (resource !== "backups/schedule") await expect(page.getByRole("button", { name: "Новый бэкап", exact: true })).toBeDisabled()
})
test("Бэкапы: поздняя зависимость не задерживает историю", async ({ page }) => {
await setupBackups(page); let release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups/storage", async r => { await gate; await r.fallback().catch(() => {}) })
await page.goto("/backups"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
release(); await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled()
})
test("Бэкапы: до ответа нет ложного empty, фоновая ошибка сохраняет строки", async ({ page }) => {
await setupBackups(page); let release!: () => void; const gate = new Promise<void>(r => release = r); let fail = false
await page.route("**/api/backups", async r => { await gate; await r.fulfill(fail ? { status: 503, json: { error: "Фоновый отказ" } } : { json: [backup] }).catch(() => {}) })
await page.goto("/backups"); await expect(page.getByText("Загрузка резервных копий…", { exact: true })).toBeVisible()
await expect(page.getByText("Нет бэкапов", { exact: true })).toHaveCount(0)
release(); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
fail = true; await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert", { name: "Данные резервных копий: backups" })).toContainText("Последние данные сохранены")
await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
})
for (const serverRead of [false, true]) test("Бэкапы: read-only и server read " + serverRead, async ({ page }) => {
await setupBackups(page)
await page.addInitScript(({ serverRead }) => localStorage.setItem("mmapp_token", "header." + btoa(JSON.stringify({ sub: "test", apps: ["mm"], permissions: ["mm:backups:read", ...(serverRead ? ["mm:servers:read"] : [])], exp: Math.floor(Date.now() / 1000) + 3600 })) + ".signature"), { serverRead })
await page.route("**/api/auth/config", r => r.fulfill({ json: { required: true } }))
let serverRequests = 0; await page.route("**/api/servers", r => { serverRequests++; return r.fallback() })
await page.goto("/backups"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Новый бэкап", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Удалить " + backup.filename, exact: true })).toBeDisabled()
await page.getByRole("tab", { name: "Настройки", exact: true }).click()
await expect(page.getByRole("button", { name: "Сохранить расписание", exact: true })).toBeDisabled()
if (!serverRead) expect(serverRequests).toBe(0)
})
test("Бэкапы: фоновое чтение сохраняет черновик хранилища", async ({ page }) => {
await setupBackups(page); await page.goto("/backups")
await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Настройки", exact: true }).click()
await page.getByRole("button", { name: "S3", exact: true }).click()
await page.getByPlaceholder("mikrotik-backups", { exact: true }).fill("draft-bucket")
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled()
await expect(page.getByPlaceholder("mikrotik-backups", { exact: true })).toHaveValue("draft-bucket")
})
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test"
import { backup, setupBackups } from "./fixtures/backups"
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Бэкапы: длинный файл и ошибки диалогов/" + name, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
const filename = "a".repeat(130) + ".rsc"
await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, filename }] }))
await page.route("**/api/backups/backup-1", r => r.fulfill({ status: 503, json: { error: "Хранилище не подтвердило удаление. Запись резервной копии сохранена." } }))
await page.route("**/api/backups/backup-1/restore", r => r.fulfill({ status: 502, json: { outcome: "unknown", error: "Связь с роутером оборвалась после отправки импорта." } }))
await page.goto("/backups"); await page.getByRole("button", { name: "Удалить " + filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Удалить", exact: true }).click(); await expect(dialog).toContainText("Запись резервной копии сохранена")
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: info.outputPath("backups-delete-error-" + name + ".png"), fullPage: true })
await dialog.getByRole("button", { name: "Отмена", exact: true }).click(); await page.getByRole("button", { name: "Восстановить " + filename, exact: true }).click()
await dialog.getByRole("button", { name: "Восстановить", exact: true }).click(); await expect(dialog).toContainText("Результат импорта неизвестен"); await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled()
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
const box = await dialog.boundingBox(); expect(box && box.x >= 0 && box.y >= 0 && box.x + box.width <= width + 1 && box.y + box.height <= 901).toBe(true)
await page.screenshot({ path: info.outputPath("backups-restore-unknown-" + name + ".png"), fullPage: true })
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0)
})
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test"
import { backup, setupBackups } from "./fixtures/backups"
for (const width of [1440, 390]) test(`Бэкапы: отсутствующий локальный файл ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, localFileStatus: "missing" }] }))
let operations = 0; page.on("request", r => { if (/\/backups\/[^/]+\/(restore|download)$/.test(new URL(r.url()).pathname)) operations++ })
await page.goto("/backups")
await expect(page.getByText("Нет файла", { exact: true })).toBeVisible()
await expect(page.getByText("Локальный файл отсутствует.", { exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Скачать " + backup.filename, exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled()
await page.getByRole("button", { name: "Удалить " + backup.filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await expect(dialog).toContainText("Удалить запись о бэкапе?"); await expect(dialog).toContainText("Будет удалена только запись из истории.")
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
expect(operations).toBe(0); expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1)).toBe(false)
})
test("Бэкапы: потеря local copy сохраняет скачивание из S3", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, storage: "both", localFileStatus: "missing" }] }))
await page.route("**/api/backups/backup-1/download", r => r.fulfill({ contentType: "text/plain; charset=utf-8", body: "# S3 fixture\n" }))
await page.goto("/backups"); await expect(page.getByText("Локальная копия отсутствует. Файл будет запрошен из S3.", { exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeEnabled()
const download = page.waitForEvent("download"); await page.getByRole("button", { name: "Скачать " + backup.filename, exact: true }).click(); expect((await download).suggestedFilename()).toBe(backup.filename)
})
for (const status of ["unknown", undefined]) test(`Бэкапы: неизвестный или старый receipt ${status}`, async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, ...(status ? { localFileStatus: status } : {}) }] }))
await page.goto("/backups"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
await expect(page.getByText("Нет файла", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Скачать " + backup.filename, exact: true })).toBeEnabled()
if (status) await expect(page.getByText("Не удалось проверить локальный файл.", { exact: true })).toBeVisible()
})
test("Бэкапы: исчезновение после первого чтения подтверждается новым receipt", async ({ page }) => {
await setupBackups(page); let missing = false
await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, localFileStatus: missing ? "missing" : "available" }] }))
await page.goto("/backups"); const restore = page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }); await expect(restore).toBeEnabled()
missing = true; await page.getByRole("button", { name: "Обновить", exact: true }).click(); await expect(restore).toBeDisabled()
await expect(page.getByText("Нет файла", { exact: true })).toBeVisible()
})
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from "@playwright/test"
import { setupBackups, backup } from "./fixtures/backups"
import { openBackup, backupJob, backupAck } from "./fixtures/backup-job"
test("Бэкапы job: single-flight POST и подтверждённый результат", async ({ page }) => {
await setupBackups(page); let posts = 0, id = "", release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups/create", async r => { posts++; id = r.request().postDataJSON().requestId; await gate; await r.fulfill({ status: 202, json: backupAck(id) }).catch(() => {}) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).dblclick()
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeDisabled()
release(); await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible()
expect(posts).toBe(1); await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible()
})
test("Бэкапы job: отклонённый POST сохраняет форму для исправления", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups/create", r => r.fulfill({ status: 403, json: { error: "Создание запрещено" } }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(dialog.getByRole("button", { name: /Снять бэкап/ })).toBeEnabled()
await dialog.getByRole("button", { name: "Назад", exact: true }).click()
await expect(dialog.getByRole("textbox", { name: "Заметка резервной копии", exact: true })).toHaveValue("manual")
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
})
for (const kind of ["device", "S3"]) test("Бэкапы job: partial " + kind + " не называется полным успехом", async ({ page }) => {
await setupBackups(page); let id = ""
await page.route("**/api/backups/create", r => { id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id, kind === "device" ? { created: [], failures: [{ serverId: "1", error: "RouterOS offline" }] } : { created: [{ ...backup, uploadError: "S3 upload denied" }] }) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText(kind === "device" ? "RouterOS offline" : "S3 upload denied")
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible()
})
test("Бэкапы job: отказ GET и повтор только чтения той же задачи", async ({ page }) => {
await setupBackups(page); let posts = 0, id = "", fail = true
await page.route("**/api/backups/create", r => { posts++; id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill(fail ? { status: 503, json: { error: "Наблюдение отказало" } } : { json: backupJob(id) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Наблюдение отказало")
fail = false; await dialog.getByRole("button", { name: "Проверить задачу", exact: true }).click()
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible(); expect(posts).toBe(1)
})
test("Бэкапы job: потерянный POST/404 после reload не запускает второй POST", async ({ page }) => {
await setupBackups(page); let posts = 0
await page.route("**/api/backups/create", r => { posts++; return r.fulfill({ status: 503, json: { error: "Неизвестный результат POST" } }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ status: 404, json: { error: "Job lost" } }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
await page.reload(); await page.getByRole("button", { name: "Задача резервного копирования", exact: true }).click()
await page.getByRole("dialog").getByRole("button", { name: "Проверить задачу", exact: true }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("После рестарта")
expect(posts).toBe(1); await expect(page.getByRole("dialog").getByRole("button", { name: "Новая задача", exact: true })).toHaveCount(0)
})
test("Бэкапы job: чужая цель не подтверждает завершение", async ({ page }) => {
await setupBackups(page); let id = ""
await page.route("**/api/backups/create", r => { id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id, { notes: "foreign" }) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("не соответствует")
await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toHaveCount(0)
})
test("Бэкапы job: неизвестный POST восстанавливается GET без второго создания", async ({ page }) => {
await setupBackups(page); let posts = 0, id = ""
await page.route("**/api/backups/create", r => { posts++; id = r.request().postDataJSON().requestId; return r.fulfill({ status: 503, json: { error: "Ack lost" } }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
await dialog.getByRole("button", { name: "Проверить задачу", exact: true }).click()
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible(); expect(posts).toBe(1)
})
test("Бэкапы job: невозможность checkpoint записи не отправляет POST", async ({ page }) => {
await setupBackups(page); let posts = 0
await page.addInitScript(() => { const set = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value) { if (key.startsWith("mmapp:backup-job:")) throw new Error("denied"); return set.call(this, key, value) } })
await page.route("**/api/backups/create", r => { posts++; return r.fallback() })
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Запрос не отправлен")
expect(posts).toBe(0)
})
+24
View File
@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test"
import { backup, server, setupBackups } from "./fixtures/backups"
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Бэкапы: клавиатура, Frame и адаптивность/" + name, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
await page.route("**/api/servers", r => r.fulfill({ json: [{ ...server, status: null }] }))
const long = { ...backup, filename: "backup-of-router-with-a-very-long-name-for-mobile-screens.rsc", serverName: "Очень длинное имя роутера для проверки адаптивности", notes: "Длинная заметка: проверка подтверждённых резервных копий и адаптивной таблицы" }
await page.route("**/api/backups", r => r.fulfill({ json: [long] }))
await page.goto("/backups"); await expect(page.getByText(long.filename, { exact: true })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "Резервные копии" })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
await page.screenshot({ path: info.outputPath("backups-history-" + name + ".png"), fullPage: true })
const settings = page.getByRole("tab", { name: "Настройки", exact: true }); await settings.focus(); await page.keyboard.press("Enter")
await expect(page.getByText("Неизвестно", { exact: true })).toBeVisible(); await expect(page.getByText("Оффлайн", { exact: true })).toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
await page.getByText("Автоматический бэкап", { exact: true }).scrollIntoViewIfNeeded()
expect(await page.locator("fieldset").evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth + 1))).toBe(true)
for (const label of ["Ежедневно", "Еженедельно", "Ежемесячно"]) { const box = await page.getByRole("button", { name: label, exact: true }).boundingBox(); expect(box && box.x >= 0 && box.x + box.width <= width + 1).toBe(true) }
await page.screenshot({ path: info.outputPath("backups-settings-" + name + ".png"), fullPage: true })
const create = page.getByRole("button", { name: "Новый бэкап", exact: true }).first(); await create.focus(); await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog", { name: "Новый бэкап", exact: true }); await expect(dialog).toBeVisible(); await expect(dialog.getByText("Неизвестно", { exact: true })).toBeVisible(); await expect(dialog.getByText("недоступен", { exact: true })).toHaveCount(0)
await expect.poll(async () => { const box = await dialog.boundingBox(); return !!box && box.x >= 0 && box.x + box.width <= width + 1 && (width >= 640 || box.width >= width - 2) }).toBe(true)
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: info.outputPath("backups-create-" + name + ".png"), fullPage: true })
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); await expect(create).toBeFocused()
})
+90
View File
@@ -0,0 +1,90 @@
import { test, expect } from "@playwright/test"
import { backup, setupBackups } from "./fixtures/backups"
test("Бэкапы: single-flight удаление, отказ refresh не отменяет 204", async ({ page }) => {
await setupBackups(page); let deletes = 0, deleted = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups", r => r.fulfill(deleted ? { status: 503, json: { error: "История после удаления отказала" } } : { json: [backup] }))
await page.route("**/api/backups/backup-1", async r => { deletes++; await gate; deleted = true; await r.fulfill({ status: 204 }).catch(() => {}) })
await page.goto("/backups"); await page.getByRole("button", { name: "Удалить " + backup.filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Удалить", exact: true }).dblclick()
await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled(); await page.keyboard.press("Escape"); await expect(dialog).toBeVisible()
release(); await expect(dialog).toBeHidden(); await expect(page.getByText(backup.filename, { exact: true })).toHaveCount(0)
await expect(page.getByText("API подтвердил удаление резервной копии", { exact: true })).toBeVisible(); expect(deletes).toBe(1)
await expect(page.getByRole("alert", { name: "Данные резервных копий: backups" })).toContainText("История после удаления отказала")
})
for (const status of [403, 503, 200]) test("Бэкапы: удаление " + status + " сохраняет цель и диалог", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups/backup-1", r => r.fulfill({ status, json: { error: "Удаление не подтверждено" } }))
await page.goto("/backups"); await page.getByRole("button", { name: "Удалить " + backup.filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Удалить", exact: true }).click()
await expect(dialog.getByRole("alert")).toBeVisible(); await expect(dialog).toContainText(backup.filename)
await expect(dialog.getByRole("button", { name: "Удалить", exact: true })).toBeEnabled()
await expect(page.getByText("API подтвердил удаление резервной копии", { exact: true })).toHaveCount(0)
})
test("Бэкапы: ошибка restore и чужой filename сохраняют диалог", async ({ page }) => {
await setupBackups(page); let failure = true
await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 400, json: { error: "Импорт отказал", outcome: "not-applied" } } : { json: { backupId: "backup-1", serverId: 1, filename: "other.rsc", serverName: "router" } }))
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
await expect(dialog.getByRole("alert").filter({ hasText: "Импорт отказал" })).toBeVisible()
failure = false; await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
await expect(dialog.getByRole("alert").filter({ hasText: "другому файлу" })).toBeVisible()
await expect(dialog).toContainText(backup.filename)
})
test("Бэкапы: download получает файл, HTML 200 показывает ошибку", async ({ page }) => {
await setupBackups(page); let html = false
await page.route("**/api/backups/backup-1/download", r => r.fulfill({ contentType: html ? "text/html" : "text/plain; charset=utf-8", body: html ? "<html>Error</html>" : "config" }))
await page.goto("/backups"); const button = page.getByRole("button", { name: "Скачать " + backup.filename, exact: true })
const download = page.waitForEvent("download"); await button.click(); expect((await download).suggestedFilename()).toBe(backup.filename)
html = true; await button.click(); await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("не подтверждён API")
})
test("Бэкапы: restore требует правильный serverId и принимает новое имя сервера", async ({ page }) => {
await setupBackups(page); let serverId = 2, calls = 0
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: { backupId: backup.id, serverId, filename: backup.filename, serverName: "Новое имя роутера" } }) })
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
await expect(dialog.getByRole("alert").filter({ hasText: "другой резервной копии или серверу" })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled()
await dialog.getByRole("button", { name: "Закрыть", exact: true }).click()
await page.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await page.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
serverId = 1; await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click(); await dialog.getByRole("button", { name: "Восстановить", exact: true }).dblclick()
await expect(dialog).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на Новое имя роутера", { exact: true })).toBeVisible(); expect(calls).toBe(2)
})
for (const outcome of ["unknown", "legacy-error"]) test("Бэкапы: неизвестный restore переживает reload/" + outcome, async ({ page }) => {
await setupBackups(page); let calls = 0
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ status: outcome === "unknown" ? 502 : 400, json: { error: "Ответ импорта потерян", ...(outcome === "unknown" ? { outcome: "unknown" } : {}) } }) })
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled(); await expect(dialog).toContainText("Результат импорта неизвестен")
await page.reload(); const alert = page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })
await expect(alert).toContainText(backup.filename); await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled()
await expect(alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true })).toBeDisabled(); expect(calls).toBe(1)
await alert.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
await expect(alert).toBeHidden(); await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeEnabled(); expect(calls).toBe(1)
})
test("Бэкапы: запрет записи checkpoint не отправляет restore", async ({ page }) => {
await setupBackups(page); let calls = 0
await page.addInitScript(() => { const original = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value) { if (key.startsWith("mmapp:backup-restore:")) throw new Error("storage denied"); return original.call(this, key, value) } })
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: {} }) })
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Восстановить", exact: true }).click()
await expect(page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })).toContainText("Запрос не отправлен"); expect(calls).toBe(0)
})
test("Бэкапы: повреждённый checkpoint запрещает новый restore", async ({ page }) => {
await setupBackups(page); await page.addInitScript(() => sessionStorage.setItem("mmapp:backup-restore:" + location.origin, "broken"))
await page.goto("/backups"); await expect(page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })).toContainText("Не удалось прочитать отметку")
await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled()
})
test("Бэкапы: подтверждённый restore с отказом очистки checkpoint не допускает новый импорт", async ({ page }) => {
await setupBackups(page); let calls = 0
await page.addInitScript(() => { const original = Storage.prototype.removeItem; Storage.prototype.removeItem = function(key) { if (key.startsWith("mmapp:backup-restore:")) throw new Error("storage denied"); return original.call(this, key) } })
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: { backupId: backup.id, serverId: backup.serverId, filename: backup.filename, serverName: "router" } }) })
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Восстановить", exact: true }).click()
await expect(page.getByRole("alertdialog")).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на router", { exact: true })).toBeVisible()
const alert = page.getByRole("alert", { name: "Подтверждение восстановления", exact: true }); await expect(alert).toContainText("Не удалось очистить отметку")
await alert.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled(); expect(calls).toBe(1)
})
+13
View File
@@ -0,0 +1,13 @@
import { test, expect } from "@playwright/test"
import { backup, setupBackups } from "./fixtures/backups"
for (const confirmed of [true, false]) test(`Бэкапы: удаление отсутствующего файла требует record-only receipt ${confirmed}`, async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, localFileStatus: "missing" }] }))
let calls = 0
await page.route("**/api/backups/backup-1?recordOnly=true", r => { calls++; return r.fulfill({ status: 204, headers: confirmed ? { "X-Backup-Delete-Mode": "record-only" } : {} }) })
await page.goto("/backups"); await page.getByRole("button", { name: "Удалить " + backup.filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Удалить", exact: true }).click()
if (confirmed) await expect(dialog).toBeHidden()
else { await expect(dialog).toBeVisible(); await expect(dialog).toContainText("Не подтверждено удаление только записи истории") }
expect(calls).toBe(1)
})
+65
View File
@@ -0,0 +1,65 @@
import { test, expect, type Page } from "@playwright/test"
import { setupBackups, schedule, storage } from "./fixtures/backups"
async function open(page: Page) { await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toBeVisible() }
const save = (page: Page) => page.getByRole("button", { name: "Сохранить расписание", exact: true })
test("Бэкапы: save расписания не сохраняет хранилище", async ({ page }) => {
await setupBackups(page); let puts = 0, storagePuts = 0, body: Record<string, unknown> = {}
await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") storagePuts++; return r.fallback() })
await page.route("**/api/backups/schedule", r => { if (r.request().method() !== "PUT") return r.fallback(); puts++; body = r.request().postDataJSON(); return r.fulfill({ json: { ...schedule, ...body } }) })
await open(page); await page.getByRole("spinbutton", { name: "Час запуска" }).fill("7"); await page.getByRole("spinbutton", { name: "Минута запуска" }).fill("7")
await save(page).click(); await expect(page.getByText("Расписание сохранено", { exact: true })).toBeVisible()
expect(puts).toBe(1); expect(storagePuts).toBe(0); expect(body.hour).toBe(7); expect(body.minute).toBe(7); expect(body.format).toBe("rsc")
})
for (const failure of ["API", "mismatch"]) test("Бэкапы: ошибка " + failure + " сохраняет расписание", async ({ page }) => {
await setupBackups(page)
await page.route("**/api/backups/schedule", r => r.request().method() !== "PUT" ? r.fallback() : r.fulfill(failure === "API" ? { status: 503, json: { error: "Расписание отказало" } } : { json: schedule }))
await open(page); await page.getByRole("spinbutton", { name: "Час запуска" }).fill("7"); await save(page).click()
await expect(page.getByText("Расписание сохранено", { exact: true })).toHaveCount(0)
await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveValue("7")
await expect(save(page)).toBeEnabled()
})
test("Бэкапы: неверные числа и пустой явный выбор не отправляются", async ({ page }) => {
await setupBackups(page); let puts = 0
await page.route("**/api/backups/schedule", r => { if (r.request().method() === "PUT") puts++; return r.fallback() })
await open(page)
for (const value of ["", "1.5", "24"]) { await page.getByRole("spinbutton", { name: "Час запуска" }).fill(value); await save(page).click(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveValue(value) }
await page.getByRole("spinbutton", { name: "Час запуска" }).fill("0")
await page.getByRole("button", { name: "Сбросить", exact: true }).click(); await save(page).click(); expect(puts).toBe(0)
})
test("Бэкапы: все включённые устройства передаются явно как пустой список", async ({ page }) => {
await setupBackups(page); let body: Record<string, unknown> = {}
await page.route("**/api/backups/schedule", r => { if (r.request().method() !== "PUT") return r.fallback(); body = r.request().postDataJSON(); return r.fulfill({ json: { ...schedule, ...body } }) })
await open(page); await page.getByRole("checkbox", { name: "Все включённые серверы", exact: true }).check(); await save(page).click()
await expect(page.getByText("Расписание сохранено", { exact: true })).toBeVisible(); expect(body.serverIds).toEqual([])
})
test("Бэкапы: общий lock не допускает второй PUT и блокирует поля", async ({ page }) => {
await setupBackups(page); let puts = 0, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/backups/schedule", async r => { if (r.request().method() !== "PUT") return r.fallback(); puts++; await gate; await r.fulfill({ json: { ...schedule, ...r.request().postDataJSON() } }).catch(() => {}) })
await open(page); await save(page).dblclick()
await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toBeDisabled()
await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeDisabled()
release(); await expect(save(page)).toBeEnabled(); expect(puts).toBe(1)
})
test("Бэкапы: отказ расписания не скрывает форму хранилища", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups/schedule", r => r.fulfill({ status: 503, json: { error: "Schedule unavailable" } }))
await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click()
await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeEnabled()
await expect(save(page)).toBeDisabled(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveCount(0)
})
test("Бэкапы: проверка S3 не сохраняет черновик незаметно", async ({ page }) => {
await setupBackups(page); let mutations = 0
await page.route("**/api/backups/storage**", r => { if (r.request().method() !== "GET") mutations++; return r.fallback() })
await open(page); await page.getByRole("button", { name: "S3", exact: true }).click()
await page.getByPlaceholder("mikrotik-backups", { exact: true }).fill("draft-bucket")
await page.getByRole("button", { name: "Проверить", exact: true }).click()
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("Сначала сохраните хранилище; проверка использует подтверждённые настройки")
expect(mutations).toBe(0)
await expect(page.getByPlaceholder("mikrotik-backups", { exact: true })).toHaveValue("draft-bucket")
})
test("Бэкапы: независимое подтверждение хранилища не отправляет расписание", async ({ page }) => {
await setupBackups(page); let schedulePuts = 0, storagePuts = 0
await page.route("**/api/backups/schedule", r => { if (r.request().method() === "PUT") schedulePuts++; return r.fallback() })
await page.route("**/api/backups/storage", r => { if (r.request().method() !== "PUT") return r.fallback(); storagePuts++; return r.fulfill({ json: { ...storage, ...r.request().postDataJSON() } }) })
await open(page); await page.getByRole("button", { name: "Сохранить хранилище", exact: true }).click()
await expect(page.getByText("Хранилище сохранено", { exact: true })).toBeVisible(); expect(storagePuts).toBe(1); expect(schedulePuts).toBe(0)
})
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test"
import { backup, storage, setupBackups } from "./fixtures/backups"
import { openBackup, backupAck, backupJob } from "./fixtures/backup-job"
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) {
test("Бэкапы: S3 ошибка и адаптивность/" + name, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
const s3 = { ...storage, provider: "s3", s3Endpoint: "https://very-long-endpoint-of-backup-storage.example.test", s3Bucket: "long-backup-bucket-name", s3AccessKeyId: "test-access", secretConfigured: true, lastTestAt: "2026-10-01T00:00:00Z" }
await page.route("**/api/backups/storage", r => r.fulfill({ json: s3 }))
await page.route("**/api/backups/storage/test", r => r.fulfill({ status: 400, json: { error: "Доступ к S3 отклонён. Проверьте сохранённые ключи и права на bucket.", settings: { ...s3, lastTestAt: "2026-10-01T01:00:00Z", lastTestError: "Доступ к S3 отклонён. Проверьте сохранённые ключи и права на bucket." } } }))
await page.goto("/backups?tab=settings"); await expect(page.getByText("Проверено ранее", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Проверить", exact: true }).click(); await expect(page.getByRole("alert", { name: "Операция резервных копий", exact: true })).toContainText("Доступ к S3 отклонён")
await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0); await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toHaveCount(0)
await page.getByLabel("Bucket", { exact: true }).scrollIntoViewIfNeeded()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
expect(await page.locator("fieldset").evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth + 1))).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-s3-error-" + name + ".png", fullPage: true })
})
test("Бэкапы: partial job и адаптивность/" + name, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page); let id = ""
await page.route("**/api/backups/create", r => { id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id, { created: [{ ...backup, uploadError: "Загрузка в S3 не подтверждена; локальная копия создана и сохранена." }] }) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Создано 1 из 1")
await expect(dialog.getByRole("alert", { name: "Результат задачи", exact: true })).toContainText("Загрузка в S3 не подтверждена")
await expect(dialog).toContainText("Завершена с ограничениями")
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0); await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible()
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-partial-job-" + name + ".png", fullPage: true })
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0)
await page.reload(); await page.getByRole("button", { name: "Задача резервного копирования", exact: true }).first().click()
await expect(page.getByRole("dialog").getByRole("alert", { name: "Результат задачи", exact: true })).toContainText("Загрузка в S3 не подтверждена")
})
test("Бэкапы: initial error не превращается в пустую историю/" + name, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
await page.route("**/api/backups", r => r.fulfill({ status: 503, json: { error: "История недоступна: повторите чтение данных." } }))
await page.goto("/backups"); await expect(page.getByRole("alert", { name: "Данные резервных копий: backups", exact: true })).toContainText("Данные пока неизвестны")
await expect(page.getByText("Нет бэкапов", { exact: true })).toHaveCount(0); await expect(page.getByLabel("Сводка бэкапов", { exact: true })).toContainText("—")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-initial-error-" + name + ".png", fullPage: true })
})
}
+56
View File
@@ -0,0 +1,56 @@
import { test, expect, type Page } from "@playwright/test"
import { setupBackups, storage } from "./fixtures/backups"
const s3 = { ...storage, provider: "s3", s3Bucket: "bucket", s3AccessKeyId: "test-access", secretConfigured: true, lastTestAt: "2026-10-01T00:00:00Z", lastTestError: null }
async function open(page: Page) { await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click(); await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeEnabled() }
const save = (page: Page) => page.getByRole("button", { name: "Сохранить хранилище", exact: true })
for (const failure of ["API", "malformed", "unconfirmed secret"]) test("Бэкапы S3: " + failure + " сохраняет секрет и черновик", async ({ page }) => {
await setupBackups(page); let puts = 0
await page.route("**/api/backups/storage", r => {
if (r.request().method() !== "PUT") return r.fulfill({ json: s3 })
puts++; const body = r.request().postDataJSON()
return r.fulfill(failure === "API" ? { status: 503, json: { error: "S3 save failed" } } : { json: failure === "malformed" ? {} : { ...s3, ...body, secretConfigured: false } })
})
await open(page); await page.getByRole("textbox", { name: "Bucket", exact: true }).fill("draft-bucket")
await page.getByLabel("Secret key", { exact: true }).fill("new-test-secret")
await save(page).click(); await expect(save(page)).toBeEnabled(); expect(puts).toBe(1)
await expect(page.getByLabel("Secret key", { exact: true })).toHaveValue("new-test-secret")
await expect(page.getByRole("textbox", { name: "Bucket", exact: true })).toHaveValue("draft-bucket")
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toBeVisible()
await expect(page.getByText("Хранилище сохранено", { exact: true })).toHaveCount(0)
})
test("Бэкапы S3: подтверждённый secret save очищает только новый секрет", async ({ page }) => {
await setupBackups(page); let body: Record<string, unknown> = {}
await page.route("**/api/backups/storage", r => {
if (r.request().method() !== "PUT") return r.fulfill({ json: s3 })
body = r.request().postDataJSON(); return r.fulfill({ json: { ...s3, ...body, secretConfigured: true, lastTestAt: null, lastTestError: null } })
})
await open(page); await page.getByLabel("Secret key", { exact: true }).fill("new-test-secret"); await save(page).click()
await expect(page.getByText("Хранилище сохранено", { exact: true })).toBeVisible(); await expect(page.getByLabel("Secret key", { exact: true })).toHaveValue("")
expect(body.s3SecretAccessKey).toBe("new-test-secret"); await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0)
})
test("Бэкапы S3: незаполненный S3 и неверный Endpoint не отправляются", async ({ page }) => {
await setupBackups(page); let puts = 0
await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") puts++; return r.fallback() })
await open(page); await page.getByRole("button", { name: "S3", exact: true }).click(); await save(page).click()
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("Bucket")
await page.getByLabel("Bucket", { exact: true }).fill("bucket"); await page.getByLabel("Access key", { exact: true }).fill("test-access"); await page.getByLabel("Secret key", { exact: true }).fill("test-secret")
await page.getByLabel("Endpoint", { exact: true }).fill("ftp://example.test"); await save(page).click()
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("http/https"); expect(puts).toBe(0)
})
test("Бэкапы S3: проверка подтверждённого хранилища делает только POST test", async ({ page }) => {
await setupBackups(page); let puts = 0, tests = 0
await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") puts++; return r.fulfill({ json: s3 }) })
await page.route("**/api/backups/storage/test", r => { tests++; return r.fulfill({ json: { ...s3, lastTestAt: "2026-10-01T01:00:00Z" } }) })
await open(page); await expect(page.getByText("Проверено ранее", { exact: true })).toBeVisible()
await expect(page.getByText("Connected", { exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Проверить", exact: true }).click()
await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toBeVisible(); expect(tests).toBe(1); expect(puts).toBe(0)
})
test("Бэкапы S3: отказ теста показывает backend receipt, не старый успех", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups/storage", r => r.fulfill({ json: s3 }))
await page.route("**/api/backups/storage/test", r => r.fulfill({ status: 400, json: { error: "S3 denied", settings: { ...s3, lastTestAt: "2026-10-01T01:00:00Z", lastTestError: "S3 denied" } } }))
await open(page); await page.getByRole("button", { name: "Проверить", exact: true }).click()
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("S3 denied")
await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0)
await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toHaveCount(0)
})
+21
View File
@@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test"
import { backup, setupBackups } from "./fixtures/backups"
test("Бэкапы URL: поиск, тип, вкладка, reload и Back/Forward", async ({ page }) => {
await setupBackups(page); await page.goto("/backups?kind=manual&q=live&keep=1#context")
await expect(page.getByPlaceholder("Поиск по файлу, серверу, заметке…")).toHaveValue("live"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Настройки", exact: true }).click(); await expect(page).toHaveURL(/tab=settings/); await expect(page).toHaveURL(/keep=1/); await expect(page).toHaveURL(/#context$/)
await page.reload(); await expect(page.getByRole("tab", { name: "Настройки", exact: true })).toHaveAttribute("aria-selected", "true")
await page.goBack(); await expect(page.getByRole("tab", { name: "История бэкапов", exact: true })).toHaveAttribute("aria-selected", "true"); await expect(page.getByPlaceholder("Поиск по файлу, серверу, заметке…")).toHaveValue("live")
await page.goForward(); await expect(page.getByRole("tab", { name: "Настройки", exact: true })).toHaveAttribute("aria-selected", "true")
})
test("Бэкапы URL: advanced Filters из ссылки сохраняют срез после reload", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [backup, { ...backup, id: "other", filename: "other.rsc", storage: "s3" }] }))
const filters = JSON.stringify({ v: 1, query: { id: "root", type: "group", combinator: "and", rules: [{ id: "rule-1", type: "rule", path: ["storage"], operator: "is", value: "s3" }] } })
await page.goto("/backups?filters=" + encodeURIComponent(filters)); await expect(page.getByText("other.rsc", { exact: true })).toBeVisible(); await expect(page.getByText(backup.filename, { exact: true })).toHaveCount(0)
await page.reload(); await expect(page.getByText("other.rsc", { exact: true })).toBeVisible(); await expect(page.getByText(backup.filename, { exact: true })).toHaveCount(0)
})
for (const raw of ["kind=bad", "filters=bad", "tab=settings&tab=history"]) test("Бэкапы URL: ошибочный контекст/" + raw, async ({ page }) => {
await setupBackups(page); await page.goto("/backups?keep=1&" + raw)
await expect(page.getByRole("alert", { name: "Контекст резервных копий", exact: true })).toBeVisible(); await expect(page.getByText(backup.filename, { exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Сбросить контекст", exact: true }).click(); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible(); await expect(page).toHaveURL(/keep=1/)
})
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test"
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
const session = (serverId: number, name = "Сессия " + serverId) => ({ id: "*1", serverId, serverName: "Роутер " + serverId, serverSite: "", serverCountry: "RU", name, peerIp: "192.0.2." + serverId, remoteAs: 64513, localAs: 64512, localId: "192.0.2.100", remoteId: "192.0.2." + serverId, state: "Established", type: "eBGP", uptime: "1h", holdTime: 90, keepalive: 30, prefixesRx: 12, prefixesTx: 2, inputMessages: 20, outputMessages: 10, capabilities: ["Тест capability"], lastError: null })
for (const change of ["backend", "mode"]) for (const fails of [false, true]) {
test(`BGP: позднее чтение после смены ${change}/${fails}`, async ({ page }) => {
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const url = new URL(r.request().url()), path = url.pathname
return r.fulfill({ headers, json: path === "/api/bgp/catalog" ? { sessions: [session(1, url.port === "8101" ? "Новая BGP" : "Старая BGP")], failures: [] } : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/settings")
await page.getByRole("link", { name: /^BGP/ }).click()
await expect(page.locator("tr[data-row-id]")).toContainText("Старая BGP")
let release: (() => void) | undefined, started = false
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/api/bgp/catalog", async r => {
if (new URL(r.request().url()).port !== "8000") return r.fallback()
started = true
await gate
return r.fulfill(fails ? { headers, status: 503, json: { error: "Старая ошибка BGP" } } : { headers, json: { sessions: [], failures: [] } })
})
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => started).toBe(true)
await page.goBack()
await expect(page).toHaveURL(/settings/)
if (change === "backend") {
const input = page.getByPlaceholder("http://localhost:8000")
await input.fill("http://localhost:8101")
await input.press("Enter")
} else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.getByRole("link", { name: /^BGP/ }).click()
if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новая BGP")
else await expect(page.getByText("Моковые данные", { exact: true })).toBeVisible()
const response = page.waitForResponse(r => r.url().includes(":8000/api/bgp/catalog"))
release!()
await (await response).finished()
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новая BGP")
await expect(page.getByText("Не удалось загрузить BGP", { exact: false })).toHaveCount(0)
})
}
+119
View File
@@ -0,0 +1,119 @@
import { test, expect } from "@playwright/test"
const server = (id: number) => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", status: null })
const session = (serverId: number, name = "Сессия " + serverId) => ({ id: "*1", serverId, serverName: "Роутер " + serverId, serverSite: "", serverCountry: "RU", name, peerIp: "192.0.2." + serverId, remoteAs: 64513, localAs: 64512, localId: "192.0.2.100", remoteId: "192.0.2." + serverId, state: "Established", type: "eBGP", uptime: "1h", holdTime: 90, keepalive: 30, prefixesRx: 12, prefixesTx: 2, inputMessages: 20, outputMessages: 10, capabilities: [], lastError: null })
const full = { sessions: [session(1), session(2)], failures: [] }
for (const broken of ["http", "payload", "duplicate", "conflict", "duplicate-failure", "foreign", "inventory"]) {
test('BGP: неизвестное начало, кэш и восстановление ' + broken, async ({ page }) => {
let phase = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/servers") return r.fulfill({ json: phase === 2 && broken === "inventory" ? [server(1), server(1)] : [server(1), server(2)] })
if (path === "/api/bgp/catalog") {
if (phase === 0 || phase === 2 && broken === "http") return r.fulfill({ status: 503, json: { error: "BGP недоступен тест" } })
const failure = { serverId: 2, serverName: "Роутер 2", error: "Таймаут тест" }
const json = phase !== 2 ? full : broken === "payload" ? { sessions: [{}], failures: [] }
: broken === "duplicate" ? { sessions: [session(1), session(1)], failures: [] }
: broken === "conflict" ? { ...full, failures: [failure] }
: broken === "duplicate-failure" ? { sessions: [], failures: [failure, failure] }
: broken === "foreign" ? { sessions: [session(3)], failures: [] } : full
return r.fulfill({ json })
}
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp")
const alert = page.getByRole("alert").filter({ hasText: "Не удалось загрузить BGP" })
await expect(alert).toContainText("Данные пока неизвестны")
await expect(page.getByText("Нет BGP-сессий", { exact: true })).toHaveCount(0)
await expect(page.getByLabel("Сводка BGP", { exact: true })).toContainText("неизвестно")
phase = 1
await alert.getByRole("button", { name: "Повторить загрузку BGP" }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
await expect(page.getByRole("option").filter({ hasText: "Роутер 1" })).toContainText("Статус неизвестен")
phase = 2
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(alert).toContainText("Показаны последние полученные данные")
await expect(rows).toHaveCount(2)
await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(2)
await page.getByRole("button", { name: "Аналитика", exact: true }).click()
await expect(page.getByRole("status").filter({ hasText: "Аналитика неизвестна" })).toBeVisible()
phase = 3
await alert.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(alert).toHaveCount(0)
await page.getByRole("button", { name: "Сессии", exact: true }).click()
await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(0)
})
}
test("BGP: частичный отказ сохраняет устройство, пустой успешный опрос очищает", async ({ page }) => {
let phase = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/servers" ? [server(1), server(2)] : path === "/api/bgp/catalog" ? phase === 0 ? full : { sessions: [session(1, "Обновлённая сессия")], failures: phase === 1 ? [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут тест" }] : [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
phase = 1
await page.getByRole("button", { name: "Обновить", exact: true }).click()
const alert = page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров BGP" })
await expect(alert).toContainText("Роутер 2: Таймаут тест")
await expect(rows.filter({ hasText: "Сессия 2" })).toContainText("Неизвестно")
await expect(rows.filter({ hasText: "Обновлённая сессия" })).toContainText("Established")
await page.getByRole("option").filter({ hasText: "Роутер 1" }).click()
await expect(page.getByLabel("Сводка BGP", { exact: true })).not.toContainText("неизвестно")
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
await expect(page.getByLabel("Сводка BGP", { exact: true })).toContainText("неизвестно")
phase = 2
await alert.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(rows).toHaveCount(0)
await expect(page.getByText("Нет BGP-сессий", { exact: true })).toBeVisible()
})
test("BGP: неизвестные поля и наблюдаемый ноль не подменяются", async ({ page }) => {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
const unknown = { ...session(1), state: "Неизвестное состояние RouterOS", remoteAs: null, localAs: null, type: "Unknown", holdTime: null, keepalive: null, prefixesRx: null, prefixesTx: null, inputMessages: null, outputMessages: null, afi: "ip,ipv6" }
return r.fulfill({ json: path === "/api/servers" ? [server(1), server(2)] : path === "/api/bgp/catalog" ? { sessions: [unknown, { ...session(2), prefixesRx: 0, prefixesTx: 0 }], failures: [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
await expect(rows.first()).toContainText("Неизвестно")
await expect(rows.first()).toContainText("AS неизвестна")
await expect(rows.first()).toContainText("Тип неизвестен")
await expect(rows.last().locator("td").nth(7)).toHaveText("0")
await rows.first().click()
await expect(page.getByText("ip,ipv6", { exact: true })).toBeVisible()
await expect(page.getByText("Нет данных API", { exact: true })).toBeVisible()
await expect(page.getByText("RouterOS Export", { exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Аналитика", exact: true }).click()
await expect(page.getByLabel("Сводка префиксов BGP")).toContainText("нет данных API")
await expect(page.getByText("Недостаточно данных о префиксах для сравнения", { exact: true })).toBeVisible()
})
for (const field of ["localAs", "remoteAs"]) for (const invalid of [0, 4294967296]) {
test("BGP: невалидная AS сохраняет снимок " + field + "=" + invalid, async ({ page }) => {
let broken = false
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/servers" ? [server(1)] : path === "/api/bgp/catalog" ? { sessions: [{ ...session(1), ...(broken ? { [field]: invalid } : {}) }], failures: [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
broken = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
const alert = page.getByRole("alert").filter({ hasText: "Не удалось загрузить BGP" })
await expect(alert).toContainText("Показаны последние полученные данные")
await expect(rows).toHaveCount(1)
await expect(rows).toContainText("Неизвестно")
broken = false
await alert.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(alert).toHaveCount(0)
await expect(rows).toContainText("Established")
})
}
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from "@playwright/test"
import { mkdirSync } from "node:fs"
const server = (id: number) => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", status: null })
const session = (serverId: number, name = "Сессия " + serverId) => ({ id: "*1", serverId, serverName: "Роутер " + serverId, serverSite: "", serverCountry: "RU", name, peerIp: "192.0.2." + serverId, remoteAs: 64513, localAs: 64512, localId: "192.0.2.100", remoteId: "192.0.2." + serverId, state: "Established", type: "eBGP", uptime: "1h", holdTime: 90, keepalive: 30, prefixesRx: 12, prefixesTx: 2, inputMessages: 20, outputMessages: 10, capabilities: ["Тест capability"], lastError: null })
for (const device of ["desktop", "mobile"] as const) {
test("BGP: layout и клавиатура " + device, async ({ page }) => {
await page.setViewportSize(device === "desktop" ? { width: 1440, height: 1000 } : { width: 390, height: 844 })
await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") })
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/servers" ? [server(1), server(2)] : path === "/api/bgp/catalog" ? { sessions: [session(1), session(2)], failures: [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp?server=2")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
const scroll = page.getByRole("button", { name: /^Idle/ }).locator("..").first()
expect(await scroll.evaluate(el => el.getBoundingClientRect().right <= innerWidth)).toBe(true)
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
mkdirSync("docs/ux-ui/screens", { recursive: true })
await page.screenshot({ path: "docs/ux-ui/screens/bgp-" + device + ".png", animations: "disabled" })
const expand = page.getByRole("button", { name: "Открыть сессию Роутер 2 · 192.0.2.2", exact: true })
await expand.focus()
await page.keyboard.press("Enter")
await expect(page.getByText("Тест capability", { exact: true })).toBeVisible()
if (device === "mobile") expect(await page.getByText("Тест capability", { exact: true }).evaluate(el => el.getBoundingClientRect().right <= innerWidth)).toBe(true)
await expect.poll(() => new URL(page.url()).searchParams.get("session")).toBe("2-*1")
await page.screenshot({ path: "docs/ux-ui/screens/bgp-detail-" + device + ".png", animations: "disabled" })
await page.getByRole("button", { name: "Аналитика", exact: true }).focus()
await page.keyboard.press("Enter")
await expect(page.getByLabel("Сводка префиксов BGP")).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/bgp-analytics-" + device + ".png", animations: "disabled" })
})
}
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from "@playwright/test"
const server = (id: number) => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", status: null })
const session = (serverId: number, name = "Сессия " + serverId) => ({ id: "*1", serverId, serverName: "Роутер " + serverId, serverSite: "", serverCountry: "RU", name, peerIp: "192.0.2." + serverId, remoteAs: 64513, localAs: 64512, localId: "192.0.2.100", remoteId: "192.0.2." + serverId, state: "Established", type: "eBGP", uptime: "1h", holdTime: 90, keepalive: 30, prefixesRx: 12, prefixesTx: 2, inputMessages: 20, outputMessages: 10, capabilities: ["Тест capability"], lastError: null })
test("BGP: URL устройства/сессии/вкладки, возврат и неизвестный контекст", async ({ page }) => {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/servers" ? [server(1), server(2)] : path === "/api/bgp/catalog" ? { sessions: [session(1), session(2)], failures: [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp?server=1&session=1-%2A1&keep=x")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.getByText("Тест capability", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Аналитика", exact: true }).click()
await expect(page).toHaveURL(/tab=analytics/)
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
await expect(page).toHaveURL(/server=2/)
await expect.poll(() => new URL(page.url()).searchParams.has("session")).toBe(false)
expect(new URL(page.url()).searchParams.get("keep")).toBe("x")
await page.goBack()
await expect(page).toHaveURL(/server=1/)
await page.getByRole("button", { name: "Сессии", exact: true }).click()
await expect(page.getByText("Тест capability", { exact: true })).toBeVisible()
await page.reload()
await expect(page.getByText("Тест capability", { exact: true })).toBeVisible()
await page.goto("/bgp?server=99")
await expect(page.getByRole("alert").filter({ hasText: "Устройство BGP недоступно" })).toBeVisible()
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await expect(page).toHaveURL(/server=99/)
await page.getByRole("button", { name: "Все устройства BGP" }).click()
await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
await page.goto("/bgp?server=1&session=missing")
await expect(page.getByRole("alert").filter({ hasText: "Сессия из ссылки отсутствует" })).toBeVisible()
await page.getByRole("button", { name: "Все сессии BGP" }).click()
await expect.poll(() => new URL(page.url()).searchParams.has("session")).toBe(false)
})
test("BGP: время успешного опроса устройства сохраняется при отказе", async ({ page }) => {
await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") })
let phase = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/bgp/catalog" && phase === 2) return r.fulfill({ status: 503, json: { error: "Общий отказ BGP" } })
return r.fulfill({ json: path === "/api/servers" ? [server(1), server(2)] : path === "/api/bgp/catalog" ? { sessions: phase === 1 ? [session(1)] : [session(1), session(2)], failures: phase === 1 ? [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут тест" }] : [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp")
const group = page.getByRole("group", { name: "Свежесть BGP" })
const first = await group.locator('[data-bgp-server="2"]').getAttribute("datetime")
phase = 1
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Таймаут тест" })).toBeVisible()
await expect(group.locator('[data-bgp-server="2"]')).toHaveAttribute("datetime", first!)
await expect(group.locator('[data-bgp-server="1"]')).not.toHaveAttribute("datetime", first!)
const after = await group.locator('[data-bgp-server="1"]').getAttribute("datetime")
phase = 2
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(page.getByRole("alert").filter({ hasText: "Общий отказ BGP" })).toBeVisible()
await expect(group.locator('[data-bgp-server="1"]')).toHaveAttribute("datetime", after!)
await expect(group.locator('[data-bgp-server="2"]')).toHaveAttribute("datetime", first!)
phase = 3
await page.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не удалось" })).toHaveCount(0)
await expect(group.locator('[data-bgp-server="2"]')).not.toHaveAttribute("datetime", first!)
})
+61
View File
@@ -0,0 +1,61 @@
import { test, expect, type Page } from "@playwright/test"
import { certificateState, server } from "./fixtures/certificates"
const acme = { directoryUrl: "https://acme.example.test/directory", defaultZoneId: "old-zone", tokenConfigured: true }
const directory = (page: Page) => page.getByRole("textbox", { name: "ACME directory URL", exact: true })
const token = (page: Page) => page.getByLabel("Cloudflare API token", { exact: true })
const save = (page: Page) => page.getByRole("button", { name: "Сохранить настройки", exact: true })
const check = (page: Page) => page.getByRole("button", { name: "Проверить Cloudflare", exact: true })
test.beforeEach(async ({ page }) => {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : path === "/api/certificates/renew-settings" ? { enabled: false, intervalSec: 300, renewBeforeDays: 30 } : acme })
})
})
for (const malformed of [false, true]) test("ACME: неизвестные настройки после отказа " + malformed, async ({ page }) => {
let fail = true; await page.route("**/api/certificates/acme-settings", r => r.fulfill(fail ? malformed ? { json: {} } : { status: 503, json: { error: "ACME чтение отказало" } } : { json: acme }))
await page.goto("/certificates"); await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toBeVisible()
await expect(save(page)).toBeDisabled(); await expect(check(page)).toBeDisabled(); await expect(directory(page)).toHaveValue("")
await expect(page.getByText("Состояние токена неизвестно.", { exact: true })).toBeVisible()
fail = false; await page.getByRole("button", { name: "Перечитать настройки ACME", exact: true }).click(); await expect(directory(page)).toHaveValue(acme.directoryUrl)
})
for (const kind of ["API", "malformed", "token-unconfirmed"]) test("ACME: отказ сохранения " + kind + " оставляет черновик и токен", async ({ page }) => {
await page.route("**/api/certificates/acme-settings", r => r.request().method() === "GET" ? r.fulfill({ json: acme }) : r.fulfill(kind === "API" ? { status: 503, json: { error: "Сохранение ACME отказало" } } : { json: kind === "malformed" ? {} : { ...acme, tokenConfigured: false } }))
await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl)
await directory(page).fill("https://new.example.test/directory"); await token(page).fill("test-draft-token"); await save(page).click()
await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toContainText("Результат сохранения не подтверждён")
await expect(token(page)).toHaveValue("test-draft-token"); await expect(directory(page)).toHaveValue("https://new.example.test/directory")
await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0)
})
test("ACME: подтверждённое сохранение очищает только принятый токен", async ({ page }) => {
let body: Record<string, unknown> = {}
await page.route("**/api/certificates/acme-settings", r => { if (r.request().method() === "GET") return r.fulfill({ json: acme }); body = r.request().postDataJSON(); return r.fulfill({ json: { ...acme, directoryUrl: body.directoryUrl } }) })
await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await token(page).fill("test-draft-token"); await save(page).click()
await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toBeVisible(); await expect(token(page)).toHaveValue("")
expect(body.cloudflareApiToken).toBe("test-draft-token")
})
test("ACME: Cloudflare отказ не меняет настройки или токен", async ({ page }) => {
await page.route("**/api/certificates/acme-settings/test", r => r.fulfill({ status: 400, json: { ok: false, message: "Cloudflare denied" } }))
await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await token(page).fill("test-draft-token"); await check(page).click()
await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toContainText("Cloudflare denied")
await expect(token(page)).toHaveValue("test-draft-token"); await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0)
})
test("ACME: save и test разделяют single flight", async ({ page }) => {
let writes = 0, tests = 0; let release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/certificates/acme-settings", r => { if (r.request().method() === "PUT") writes++; return r.fulfill({ json: acme }) })
await page.route("**/api/certificates/acme-settings/test", async r => { tests++; await gate; await r.fulfill({ json: { ok: true, message: "Cloudflare доступен" } }) })
await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await check(page).click()
await expect(save(page)).toBeDisabled(); await expect(check(page)).toBeDisabled(); await expect(token(page)).toBeDisabled(); expect(writes).toBe(0); expect(tests).toBe(1)
release(); await expect(page.getByRole("status").filter({ hasText: "Cloudflare доступен" })).toBeVisible()
})
test("ACME: background read не стирает черновик", async ({ page }) => {
let second = false; await page.route("**/api/certificates/acme-settings", r => r.fulfill({ json: { ...acme, directoryUrl: second ? "https://server-new.example.test/directory" : acme.directoryUrl } }))
await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await directory(page).fill("https://draft.example.test/directory"); await token(page).fill("test-draft-token")
second = true; await page.getByRole("button", { name: "Перечитать настройки ACME", exact: true }).click(); await expect(directory(page)).toBeEnabled()
await expect(directory(page)).toHaveValue("https://draft.example.test/directory"); await expect(token(page)).toHaveValue("test-draft-token")
})
test("ACME: неверный URL не отправляет mutation", async ({ page }) => {
let writes = 0; await page.route("**/api/certificates/acme-settings", r => { if (r.request().method() === "PUT") writes++; return r.fulfill({ json: acme }) })
await page.goto("/certificates"); await expect(directory(page)).toHaveValue(acme.directoryUrl); await directory(page).fill("bad"); await save(page).click()
await expect(page.getByRole("alert", { name: "Настройки ACME", exact: true })).toContainText("корректный URL"); expect(writes).toBe(0)
})
+81
View File
@@ -0,0 +1,81 @@
import {test,expect} from "@playwright/test"
import {certificate as container,server,certificateState as state} from "./fixtures/certificates"
import {openIssue,issueJob} from "./fixtures/certificate-issue"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
for(const change of ["backend","mode"])for(const fails of [false,true])test("Сертификаты: позднее чтение/"+change+"/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:p==="/api/certificates/state"?state():{directoryUrl:"https://acme.example.test/directory",tokenConfigured:false,enabled:false,intervalSec:300,renewBeforeDays:30}})})
await page.route("**/api/certificates/state",async r=>{if(new URL(r.request().url()).port!=="8000")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА сертификатов"}}:{headers,json:state([{...container,name:"СТАРЫЙ СЕРТИФИКАТ"}])})}catch{}finally{completed=true}})
await page.goto("/certificates");await expect.poll(()=>started).toBe(true);await page.goto("/settings")
if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click()
await page.goto("/certificates");release();await expect.poll(()=>completed).toBe(true)
if(change==="backend")await expect(page.getByText(container.name,{exact:true})).toBeVisible();else await expect(page.getByText("Моковые данные",{exact:true})).toBeVisible()
await expect(page.getByText("СТАРЫЙ СЕРТИФИКАТ",{exact:true})).toHaveCount(0);await expect(page.getByText(/СТАРАЯ ОШИБКА сертификатов/)).toHaveCount(0)
})
for (const fails of [false, true]) test("Сертификаты: позднее сохранение renew/" + fails, async ({ page }) => {
let started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ headers, json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? state() : path === "/api/certificates/renew-settings" ? { enabled: true, intervalSec: 21600, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false } })
})
await page.route("**/api/certificates/renew-settings", async r => {
if (r.request().method() !== "PUT") return r.fallback()
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРОЕ СОХРАНЕНИЕ ОТКАЗ" } } : { headers, json: { enabled: false, intervalSec: 21600, renewBeforeDays: 30 } }) } catch {} finally { completed = true }
})
await page.goto("/certificates"); const toggle = page.getByRole("switch", { name: "Автообновление сертификатов", exact: true })
await expect(toggle).toBeChecked(); await toggle.click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings"); const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter")
await page.goto("/certificates"); await expect(toggle).toBeChecked(); release(); await expect.poll(() => completed).toBe(true)
await expect(toggle).toBeChecked(); await expect(page.getByText("СТАРОЕ СОХРАНЕНИЕ ОТКАЗ", { exact: true })).toHaveCount(0)
await expect(page.getByText("Автообновление выключено", { exact: true })).toHaveCount(0)
})
for (const fails of [false, true]) test("Сертификаты: позднее сохранение ACME/" + fails, async ({ page }) => {
let started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ headers, json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? state() : path === "/api/certificates/renew-settings" ? { enabled: true, intervalSec: 21600, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false } })
})
await page.route("**/api/certificates/acme-settings", async r => {
if (r.request().method() !== "PUT") return r.fallback()
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРОЕ СОХРАНЕНИЕ ACME ОТКАЗ" } } : { headers, json: { directoryUrl: "https://old.example.test/directory", tokenConfigured: true } }) } catch {} finally { completed = true }
})
await page.goto("/certificates"); const directory = page.getByRole("textbox", { name: "ACME directory URL", exact: true })
await expect(directory).toHaveValue("https://acme.example.test/directory"); await directory.fill("https://old.example.test/directory"); await page.getByRole("button", { name: "Сохранить настройки", exact: true }).click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings"); const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter")
await page.goto("/certificates"); await expect(directory).toHaveValue("https://acme.example.test/directory"); release(); await expect.poll(() => completed).toBe(true)
await expect(directory).toHaveValue("https://acme.example.test/directory"); await expect(page.getByText("СТАРОЕ СОХРАНЕНИЕ ACME ОТКАЗ", { exact: true })).toHaveCount(0)
await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0)
})
for (const change of ["backend", "mode"]) for (const stage of ["POST", "poll"]) for (const fails of [false, true]) test("Сертификаты: поздний выпуск/" + change + "/" + stage + "/" + fails, async ({ page }) => {
let request: Record<string, unknown> = {}, started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ headers, json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? state() : path === "/api/certificates/renew-settings" ? { enabled: true, intervalSec: 21600, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: true } })
})
await page.route("**/api/certificates/issue", async r => {
request = r.request().postDataJSON()
if (stage !== "POST") return r.fulfill({ headers, json: { jobId: request.requestId } })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ ВЫПУСК ОТКАЗ" } } : { headers, json: { jobId: request.requestId } }) } catch {} finally { completed = true }
})
await page.route("**/api/certificates/issue/*", async r => {
if (stage !== "poll") return r.fulfill({ headers, json: issueJob(request) })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ ВЫПУСК ОТКАЗ" } } : { headers, json: issueJob(request) }) } catch {} finally { completed = true }
})
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings")
if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") } else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.goto("/certificates"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByRole("button", { name: "Задача выпуска", exact: true })).toHaveCount(0)
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toHaveCount(0)
await expect(page.getByText(/СТАРЫЙ ВЫПУСК ОТКАЗ/)).toHaveCount(0)
})
+85
View File
@@ -0,0 +1,85 @@
import { test, expect, type Page } from "@playwright/test"
import { certificate, certificateState, server } from "./fixtures/certificates"
const refresh = (page: Page) => page.getByRole("button", { name: "Обновить", exact: true })
test.beforeEach(async ({ page }) => {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false, enabled: false, intervalSec: 300, renewBeforeDays: 30 } })
})
})
for (const kind of ["API", "malformed", "duplicate", "foreign"] as const) test("Сертификаты: первоначальный отказ " + kind + " и восстановление", async ({ page }) => {
let fail = true
await page.route("**/api/certificates/state", r => r.fulfill(fail ? kind === "API" ? { status: 503, json: { error: "Отказ чтения" } } : { json: kind === "malformed" ? {} : certificateState(kind === "duplicate" ? [certificate, certificate] : [{ ...certificate, serverId: "2" }]) } : { json: certificateState() }))
await page.goto("/certificates")
await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Сертификаты пока неизвестны")
await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0)
fail = false; await refresh(page).click()
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
})
test("Сертификаты: nullable поля и неизвестная классификация", async ({ page }) => {
await page.goto("/certificates")
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
await expect(page.getByText("Срок неизвестен", { exact: true })).toBeVisible()
await expect(page.getByText("Неизвестен", { exact: true })).toBeVisible()
await page.getByText(certificate.name, { exact: true }).click()
await expect(page.getByText("Неизвестно", { exact: true })).toBeVisible()
await expect(page.getByText("0 бит", { exact: true })).toHaveCount(0)
await expect(page.getByRole("status").filter({ hasText: "Сводка неполная" })).toBeVisible()
await expect(page.getByRole("button", { name: "Импорт", exact: true })).toBeDisabled()
})
test("Сертификаты: ошибка сервера не скрывает строки", async ({ page }) => {
await page.route("**/api/servers", r => r.fulfill({ status: 503, json: { error: "Серверы отказали" } }))
await page.goto("/certificates")
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
await expect(page.getByRole("alert", { name: "Серверы сертификатов" })).toContainText("Каталог серверов пока неизвестен")
await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeDisabled()
})
test("Сертификаты: общий фоновый отказ сохраняет данные", async ({ page }) => {
let fail = false
await page.route("**/api/certificates/state", r => r.fulfill(fail ? { status: 503, json: { error: "Фоновый отказ" } } : { json: certificateState() }))
await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
fail = true; await refresh(page).click()
await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Последние данные сохранены")
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
})
test("Сертификаты: частичный отказ и подтверждённый пустой снимок", async ({ page }) => {
let phase = 0
await page.route("**/api/certificates/state", r => r.fulfill({ json: phase === 0 ? certificateState() : certificateState([], { error: phase === 1 ? "Устройство недоступно" : null }) }))
await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
phase = 1; await refresh(page).click()
await expect(page.getByRole("alert", { name: "Сертификаты сервера 1", exact: true })).toContainText("Последние сертификаты сохранены")
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
phase = 2; await refresh(page).click()
await expect(page.getByText("Нет сертификатов", { exact: true })).toBeVisible()
await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0)
})
test("Сертификаты: медленные серверы не задерживают строки", async ({ page }) => {
let release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/servers", async r => { await gate; await r.fulfill({ json: [server] }).catch(() => {}) })
await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
release(); await expect(refresh(page)).toBeEnabled()
})
test("Сертификаты: до ответа нет ложного пустого каталога", async ({ page }) => {
let release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/certificates/state", async r => { await gate; await r.fulfill({ json: certificateState() }).catch(() => {}) })
await page.goto("/certificates"); await expect(page.getByRole("status").filter({ hasText: "Загрузка сертификатов…" })).toBeVisible()
await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0)
release(); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
})
for (const serverRead of [false, true]) test("Сертификаты: read-only права и server read " + serverRead, async ({ page }) => {
await page.addInitScript(({ serverRead }) => localStorage.setItem("mmapp_token", "header." + btoa(JSON.stringify({ sub: "test", apps: ["mm"], permissions: ["mm:certificates:read", ...(serverRead ? ["mm:servers:read"] : [])], exp: Math.floor(Date.now() / 1000) + 3600 })) + ".signature"), { serverRead })
await page.route("**/api/auth/config", r => r.fulfill({ json: { required: true } }))
let serverRequests = 0
await page.route("**/api/servers", r => { serverRequests++; return r.fulfill({ json: [server] }) })
await page.goto("/certificates")
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Сохранить настройки", exact: true })).toHaveCount(0)
if (!serverRead) {
await expect(page.getByRole("status").filter({ hasText: "mm:servers:read" })).toBeVisible()
expect(serverRequests).toBe(0)
}
})
+63
View File
@@ -0,0 +1,63 @@
import { test, expect } from "@playwright/test"
import { certificateRoutes, issueJob, openIssue } from "./fixtures/certificate-issue"
test.beforeEach(async ({ page }) => certificateRoutes(page))
test("Выпуск: успех и отказ последующего каталога разделены", async ({ page }) => {
let request: Record<string, unknown> = {}; let writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request) }))
await page.route("**/api/certificates/state", r => writes ? r.fulfill({ status: 503, json: { error: "Повторное чтение отказало" } }) : r.fallback())
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Закрыть", exact: true }).last().click()
await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Последние данные сохранены")
expect(writes).toBe(1)
})
test("Выпуск: GET ошибка и reload продолжают ту же задачу без POST", async ({ page }) => {
let request: Record<string, unknown> = {}, fail = true, writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill(fail ? { status: 503, json: { error: "Опрос отказал" } } : { json: issueJob(request) }))
let dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Продолжите проверку той же задачи")
await page.reload(); await page.getByRole("button", { name: "Задача выпуска", exact: true }).click()
dialog = page.getByRole("dialog", { name: "Выпуск сертификата", exact: true }); fail = false
await dialog.getByRole("button", { name: "Продолжить проверку", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled(); expect(writes).toBe(1)
})
for (const accepted of [false, true]) test("Выпуск: потерянное подтверждение POST/accepted=" + accepted, async ({ page }) => {
const requests: Record<string, unknown>[] = []; let exists = false
await page.route("**/api/certificates/issue", r => { const request = r.request().postDataJSON(); requests.push(request); exists = accepted || requests.length > 1; return r.fulfill(requests.length === 1 ? { status: 503, json: { error: "Ответ потерян" } } : { json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill(exists ? { json: issueJob(requests[0]) } : { status: 404, json: { error: "Задача не найдена" } }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Ответ потерян")
await dialog.getByRole("button", { name: "Продолжить проверку", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
expect(requests.length).toBe(accepted ? 1 : 2); for (const request of requests) expect(request.requestId).toBe(requests[0].requestId)
})
test("Выпуск: реальный failed и новая форма не повторяют POST автоматически", async ({ page }) => {
let request: Record<string, unknown> = {}, writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request, { status: "failed", step: "failed", error: "Импорт отказал" }) }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Импорт отказал")
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
await dialog.getByRole("button", { name: "Новый выпуск", exact: true }).click()
await expect(dialog.getByRole("textbox", { name: "Имя сертификата", exact: true })).toHaveValue(""); expect(writes).toBe(1)
})
test("Выпуск: чужая цель в done не подтверждает успех", async ({ page }) => {
let request: Record<string, unknown> = {}
await page.route("**/api/certificates/issue", r => { request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request, { serverId: "2" }) }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("не соответствует")
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Продолжить проверку", exact: true })).toBeEnabled()
})
test("Выпуск: отказ записи checkpoint не отправляет запрос", async ({ page }) => {
await page.addInitScript(() => { sessionStorage.setItem = () => { throw new Error("storage denied") } })
let writes = 0; await page.route("**/api/certificates/issue", r => { writes++; return r.fulfill({ json: {} }) })
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Не удалось сохранить задачу")
expect(writes).toBe(0)
})
+24
View File
@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test"
import { certificateRoutes, openIssue } from "./fixtures/certificate-issue"
import { certificate, certificateState } from "./fixtures/certificates"
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Сертификаты: клавиатура и адаптивность " + name, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await certificateRoutes(page)
const long = { ...certificate, name: "certificate-with-a-very-long-name-for-narrow-screens", commonName: "very-long-subdomain-for-responsive-layout.example.test", sans: ["very-long-alternative-domain-for-responsive-layout.example.test"] }
await page.route("**/api/certificates/state", r => r.fulfill({ json: certificateState([long]) }))
await page.goto("/certificates"); await expect(page.getByText(long.name, { exact: true })).toBeVisible()
const summary = page.getByLabel("Сводка сертификатов", { exact: true }); await expect(summary).toContainText("1")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
const detail = page.getByRole("button", { name: "Детали сертификата " + long.name, exact: true }); await detail.focus(); await page.keyboard.press("Enter")
await expect(detail).toHaveAttribute("aria-expanded", "true"); await expect(page.getByText("Доверие", { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath("certificates-details-" + name + ".png"), fullPage: true })
await page.evaluate(() => { for (const element of document.querySelectorAll<HTMLElement>("*")) if (element.scrollTop > 0) element.scrollTop = 0 })
await page.screenshot({ path: info.outputPath("certificates-overview-" + name + ".png"), fullPage: true })
await page.keyboard.press("Enter"); await expect(detail).toHaveAttribute("aria-expanded", "false")
const search = page.getByRole("textbox", { name: "Поиск по имени, CN, эмитенту…" }); await search.fill("нет-совпадений"); await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible(); await search.fill("")
const dialog = await openIssue(page)
await expect.poll(async () => { const box = await dialog.boundingBox(); return !!box && box.x >= 0 && box.x + box.width <= width + 1 && (width >= 640 || box.width >= width - 2) }).toBe(true)
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: info.outputPath("certificates-issue-" + name + ".png"), fullPage: true })
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0)
await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeFocused()
})
+50
View File
@@ -0,0 +1,50 @@
import { test, expect, type Page } from "@playwright/test"
import { certificateState, server } from "./fixtures/certificates"
const renew = { enabled: true, intervalSec: 21600, renewBeforeDays: 30 }
const interval = (page: Page) => page.getByRole("textbox", { name: "Интервал проверки сертификатов", exact: true })
const save = (page: Page) => page.getByRole("button", { name: "Сохранить расписание", exact: true })
const toggle = (page: Page) => page.getByRole("switch", { name: "Автообновление сертификатов", exact: true })
test.beforeEach(async ({ page }) => {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : path === "/api/certificates/renew-settings" ? renew : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false } })
})
})
for (const malformed of [false, true]) test("Автообновление: первоначальный отказ " + malformed + " не разрешает defaults", async ({ page }) => {
let fail = true
await page.route("**/api/certificates/renew-settings", r => r.fulfill(fail ? malformed ? { json: {} } : { status: 503, json: { error: "Отказ расписания" } } : { json: renew }))
await page.goto("/certificates")
await expect(page.getByRole("alert", { name: "Настройки автообновления" })).toBeVisible()
await expect(toggle(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); await expect(interval(page)).toHaveValue("")
fail = false; await page.getByRole("button", { name: "Перечитать настройки автообновления" }).click()
await expect(interval(page)).toHaveValue("21600"); await expect(toggle(page)).toBeChecked()
})
for (const malformed of [false, true]) test("Автообновление: отказ сохранения " + malformed + " сохраняет черновик", async ({ page }) => {
await page.route("**/api/certificates/renew-settings", r => r.request().method() === "PUT" ? r.fulfill(malformed ? { json: {} } : { status: 503, json: { error: "Сохранение отказало" } }) : r.fulfill({ json: renew }))
await page.goto("/certificates"); await expect(interval(page)).toHaveValue("21600")
await interval(page).fill("900"); await save(page).click()
await expect(page.getByRole("alert", { name: "Настройки автообновления" })).toContainText("результат операции не подтверждён")
await expect(interval(page)).toHaveValue("900"); await expect(toggle(page)).toBeChecked()
await expect(page.getByText("Расписание автообновления сохранено", { exact: true })).toHaveCount(0)
})
for (const value of ["bad", "299", "300oops", "1.5"]) test("Автообновление: invalid interval " + value + " не подменяется default", async ({ page }) => {
let writes = 0; await page.route("**/api/certificates/renew-settings", r => { if (r.request().method() === "PUT") writes++; return r.fulfill({ json: renew }) })
await page.goto("/certificates"); await expect(interval(page)).toHaveValue("21600")
await interval(page).fill(value); await save(page).click()
await expect(page.getByRole("alert", { name: "Настройки автообновления" })).toContainText("Введите целые числа")
expect(writes).toBe(0); await expect(interval(page)).toHaveValue(value)
})
test("Автообновление: single flight переключателя и подтверждённый false", async ({ page }) => {
let writes = 0; let release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/api/certificates/renew-settings", async r => { if (r.request().method() === "GET") return r.fulfill({ json: renew }); writes++; await gate; await r.fulfill({ json: { ...renew, enabled: false } }) })
await page.goto("/certificates"); await expect(toggle(page)).toBeChecked(); await toggle(page).click()
await expect(toggle(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); expect(writes).toBe(1)
release(); await expect(toggle(page)).not.toBeChecked(); await expect(page.getByText("Автообновление выключено", { exact: true })).toBeVisible()
})
test("Автообновление: фоновое чтение не стирает dirty черновик", async ({ page }) => {
let second = false; await page.route("**/api/certificates/renew-settings", r => r.fulfill({ json: { ...renew, intervalSec: second ? 7200 : 21600 } }))
await page.goto("/certificates"); await expect(interval(page)).toHaveValue("21600"); await interval(page).fill("900")
second = true; await page.getByRole("button", { name: "Перечитать настройки автообновления" }).click()
await expect(interval(page)).toBeEnabled(); await expect(interval(page)).toHaveValue("900")
})
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from "@playwright/test"
import { certificate, certificateState, server } from "./fixtures/certificates"
test.beforeEach(async ({ page }) => {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false, enabled: false, intervalSec: 300, renewBeforeDays: 30 } })
})
})
for (const query of ["server=bad", "server=1&server=2", "status=bad", "q=a&q=b"]) test("Сертификаты URL: неверный контекст " + query, async ({ page }) => {
await page.goto("/certificates?" + query + "&keep=map#detail")
await expect(page.getByRole("alert", { name: "Контекст сертификатов", exact: true })).toBeVisible()
await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Сбросить контекст", exact: true }).click()
expect(new URL(page.url()).search).toBe("?keep=map")
expect(new URL(page.url()).hash).toBe("#detail")
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
})
test("Сертификаты URL: фильтр, reload и Back", async ({ page }) => {
await page.goto("/certificates?server=1&status=unknown&q=certificate&keep=map#detail")
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Действующие", exact: true }).click()
await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible()
expect(new URL(page.url()).searchParams.get("status")).toBe("valid")
await page.goBack(); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
await page.reload(); await expect(page.getByRole("textbox", { name: "Поиск по имени, CN, эмитенту…" })).toHaveValue("certificate")
await expect(page.getByRole("button", { name: "Неизвестные", exact: true })).toHaveAttribute("aria-pressed", "true")
expect(new URL(page.url()).hash).toBe("#detail")
})
test("Сертификаты URL: недоступный сервер не заменяется всеми", async ({ page }) => {
await page.goto("/certificates?server=2&keep=map")
await expect(page.getByRole("alert").filter({ hasText: "Выбранный сервер недоступен" })).toBeVisible()
await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0)
await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0)
expect(new URL(page.url()).searchParams.get("server")).toBe("2")
})
+184
View File
@@ -0,0 +1,184 @@
import { SCHEDULER_JOB_KEYS } from "../../lib/scheduler-settings"
import { test, expect, type Page } from "@playwright/test"
import axe from "axe-core"
import type { SpeedProbeApiRow } from "../../components/data-grids/probes-speed-probes-data-grid"
async function setup(page: Page, path: string, count = 12, grouped = false) {
const probes: SpeedProbeApiRow[] = Array.from({ length: count }, (_, i) => ({
id: String(i + 1), srcServerId: String(i + 1), dstServerId: String(count + 1), srcInterface: "ether1",
dstInterface: "ether1", protocol: "tcp", direction: "both", durationSec: "10", enabled: true,
lastRunAt: null, lastTxAvgMbps: null, lastRxAvgMbps: null, lastStatus: null, lastError: null,
}))
const jobs = SCHEDULER_JOB_KEYS.map(jobKey => ({ jobKey, enabled: true, intervalSec: 300, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null }))
const calls: string[] = []
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (route.request().method() === "POST") calls.push(path)
if (path === "/api/scheduler/status") return route.fulfill({ json: { jobs } })
if (path === "/api/scheduler/jobs/alert_engine/run-now") return route.fulfill({ json: { ok: true, result: { status: "ok", error: null, result: { v: 1, job: "alert_engine", sampledAt: "2026-10-03T00:00:00Z", rulesChecked: 0, standaloneFires: 0, groupFires: 0, skippedNoTelegram: false } }, status: { jobs } } })
return route.fulfill({ json: path === "/api/uptime/speed-probes" ? { probes }
: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? Array.from({length:count+1},(_,i)=>({id:i+1,name:i<count?`Источник ${i+1}`:"Цель",host:`192.0.2.${i%250+1}`,type:"exit-node",enabled:true,country:"RU",site:grouped?(i%2?"Площадка B":"Площадка A"):"TEST",asn:"",status:null,latency:null,os:"7",model:"CHR",sessions:0,wanUplinks:[]}))
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
: path === "/api/traffic/flow/settings" ? {
enabled: false, collectorIp: "10.0.0.1", flowListenPort: 2055, wgListenPort: 51820,
prefix: "10.0.0.0/24", publicEndpoint: "", hostPublicKey: "", hasHostPrivateKey: false,
hubServerId: null, retentionHours: 24, topN: 10, mapServiceMinSharePct: 1,
lastDatagramAt: null, lastExporterIp: null, lastError: null, packetsReceived: 0,
listenerBound: false, listenerAddress: null, peers: [],
}
: path === "/api/geoip" ? { ready: false, running: false, countryFile: "", asnFile: "", countryLoaded: false, asnLoaded: false, dir: "storage/geoip", settings: { enabled: false, updateIntervalSec: 86400, lastCheckAt: null, lastSuccessAt: null, lastError: null, countryBuildAt: null, asnBuildAt: null, updatedAt: "2026-09-14" } }
: path === "/api/scheduler/runs" ? { runs: [] }
: path.endsWith("/settings") || path.endsWith("/renew-settings") ? {
enabled: true, intervalSec: 300, retentionDays: 7, probeIntervalSec: 15, speedIntervalSec: 60,
resourcesEnabled: true, pingEnabled: true, speedEnabled: true, lastCollectedAt: null, lastDurationMs: 0, lastError: null, renewBeforeDays: 30, scheduler: { jobs: [] },
} : {} })
})
await page.goto(path)
return calls
}
test("Speed probes: 12 записей и сортировка источника", async ({ page }) => {
await setup(page, "/probes")
await page.getByRole("button", { name: "Расписание", exact: true }).click()
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")
})
test("Сбор данных: все 11 задач и запуск выбранной задачи через API", async ({ page }) => {
const calls = await setup(page, "/data-collection")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(11)
await expect(rows.last()).toContainText("Оповещения")
await page.getByRole("button", { name: "Запустить: Оповещения", exact: true }).click()
await expect.poll(() => calls).toContain("/api/scheduler/jobs/alert_engine/run-now")
await expect(rows).toHaveCount(11)
await expect(page.getByText("Запуск подтверждён: Оповещения", { exact: true })).toBeVisible()
})
test("Демо-расписание: сортировка интервала и удаление выбранного правила", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("routerlists:data-source", "mock"))
await setup(page, "/probes")
await page.getByRole("button", { name: "Расписание", exact: true }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(3)
await page.getByRole("button", { name: "Интервал", exact: true }).click()
await expect(rows.first()).toHaveAttribute("data-row-id", "r3")
await rows.first().getByRole("button", { name: "Удалить правило", exact: true }).click()
await expect(rows).toHaveCount(2)
await expect(page.locator('tr[data-row-id="r3"]')).toHaveCount(0)
})
test("Large speed probes: 50 строк, глобальная сортировка и контекст обновления", async ({ page }, info) => {
test.setTimeout(90000)
const start = Date.now()
const writes = await setup(page, "/probes", 1000)
await page.getByRole("button", { name: "Расписание", exact: true }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(50)
const sample = { fixtureRows: 1000, renderedRows: await rows.count(), domNodes: await page.locator("*").count(), scheduleNodes: await page.getByRole("region", { name: "Пробы скорости", exact: true }).locator("*").count(), readyMs: Date.now() - start }
console.log(JSON.stringify(sample))
await info.attach("large-speed-probes-metrics", { body: JSON.stringify(sample), contentType: "application/json" })
expect(sample.scheduleNodes).toBeLessThan(2000)
expect(sample.domNodes).toBeLessThan(2500)
await page.getByRole("button", { name: "Следующая страница", exact: true }).click()
await expect(rows.first()).toHaveAttribute("data-row-id", "51")
await page.getByRole("button", { name: "Обновить пробы скорости", exact: true }).click()
await expect(page.getByRole("button", { name: "Обновить пробы скорости", exact: true })).toBeEnabled()
await expect(page.getByText("51–100 из 1000", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Источник", exact: true }).click()
await page.getByRole("button", { name: "Источник", exact: true }).click()
await page.getByRole("button", { name: "Перейти на страницу 1", exact: true }).click()
await expect(rows.first()).toHaveAttribute("data-row-id", "1000")
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "…", exact: true }).last().click()
await page.getByRole("button", { name: "Перейти на страницу 20", exact: true }).click()
await expect(page.getByText("951–1000 из 1000", { exact: true })).toBeVisible()
const panel = page.getByRole("region", { name: "Пробы скорости", exact: true })
const freshness = await panel.getByRole("status").textContent()
await page.route("**/api/uptime/speed-probes", route => route.fulfill({ status: 503, json: { error: "Большой каталог недоступен" } }))
await panel.getByRole("button", { name: "Обновить пробы скорости", exact: true }).click()
await expect(panel.getByRole("alert")).toContainText("Показан последний успешный список")
await expect(panel.getByRole("status")).toHaveText(freshness!)
await expect(rows).toHaveCount(50)
await expect(page.getByText("951–1000 из 1000", { exact: true })).toBeVisible()
await page.setViewportSize({ width: 320, height: 900 })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.route("**/api/uptime/speed-probes", route => route.fulfill({ json: { probes: [{ id: "1", srcServerId: "1", dstServerId: "1001", srcInterface: "ether1", dstInterface: "ether1", protocol: "tcp", direction: "both", durationSec: "10", enabled: true, lastRunAt: null, lastTxAvgMbps: null, lastRxAvgMbps: null, lastStatus: null, lastError: null }] } }))
await page.getByRole("button", { name: "Обновить пробы скорости", exact: true }).click()
await expect(rows).toHaveCount(1)
await expect(rows.first()).toHaveAttribute("data-row-id", "1")
await expect(panel.getByRole("alert")).toHaveCount(0)
expect(writes).toEqual([])
})
for (const theme of ["dark", "light"]) test(`Large server rail: URL, страницы, поиск и клавиатура на1001 устройстве/${theme}`, async ({ page }, info) => {
await page.addInitScript(value => localStorage.setItem("rl-theme", value), theme)
await setup(page, "/probes?server=1000&keep=1", 1000)
const rail = page.locator("aside").filter({ has: page.getByRole("listbox", { name: "Серверы", exact: true }) })
const list = rail.getByRole("listbox", { name: "Серверы", exact: true })
await expect(list.getByRole("option")).toHaveCount(50)
await expect(list.getByRole("option", { selected: true })).toContainText("Источник 1000")
await expect(rail.getByText("951–1000 из 1001", { exact: true })).toBeVisible()
expect(await rail.locator("*").count()).toBeLessThan(1200)
await info.attach("large-rail-metrics", { body: JSON.stringify({ fixtureDevices: 1001, renderedOptions: await list.getByRole("option").count(), railNodes: await rail.locator("*").count(), domNodes: await page.locator("*").count() }), contentType: "application/json" })
await list.getByRole("option", { selected: true }).focus()
await page.keyboard.press("End")
await expect(page).toHaveURL(url => url.searchParams.get("server") === "1001" && url.searchParams.get("keep") === "1")
await expect(list.getByRole("option")).toHaveCount(1)
await expect(list.getByRole("option", { selected: true })).toContainText("Цель")
await page.keyboard.press("Home")
await expect(page).toHaveURL(url => url.searchParams.get("server") === "1" && url.searchParams.get("keep") === "1")
await expect(list.getByRole("option")).toHaveCount(50)
await page.keyboard.press("ArrowDown")
await expect(page).toHaveURL(url => url.searchParams.get("server") === "2" && url.searchParams.get("keep") === "1")
await expect(list.getByRole("option", { selected: true })).toContainText("Источник 2")
await rail.getByRole("button", { name: "Следующие устройства", exact: true }).click()
await expect(list.getByRole("option").first()).toContainText("Источник 51")
await page.getByRole("button", { name: "Обновить устройства диагностики", exact: true }).click()
await expect(page.getByRole("button", { name: "Обновить устройства диагностики", exact: true })).toBeEnabled()
await expect(rail.getByText("51–100 из 1001", { exact: true })).toBeVisible()
const search = rail.getByRole("textbox", { name: "Поиск сервера", exact: true })
await search.fill("Источник 1000")
await expect(list.getByRole("option")).toHaveCount(1)
await list.getByRole("option").click()
await expect(page).toHaveURL(url => url.searchParams.get("server") === "1000" && url.searchParams.get("keep") === "1")
await search.fill("")
await expect(list.getByRole("option", { selected: true })).toContainText("Источник 1000")
await page.setViewportSize({ width: 320, height: 900 })
await page.getByRole("button", { name: "Серверы", exact: true }).click()
const sheet = page.getByRole("dialog")
await expect(sheet.getByRole("option")).toHaveCount(50)
await expect(sheet.getByRole("option", { selected: true })).toContainText("Источник 1000")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
expect(await sheet.getByRole("button", { name: "Следующие устройства", exact: true }).evaluate(el => el.getBoundingClientRect().right <= innerWidth)).toBe(true)
await sheet.getByRole("option", { selected: true }).focus(); await expect(sheet.getByRole("option", { selected: true })).toBeFocused()
await page.addScriptTag({ content: axe.source })
const audit = await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[role="dialog"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }))
await info.attach("large-rail-axe", { body: JSON.stringify(audit), contentType: "application/json" })
expect(audit.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }))).toEqual([])
await page.screenshot({ path: `test-results/large-server-rail-mobile-${theme}.png`, animations: "disabled" })
await page.keyboard.press("Escape")
await expect(page.getByRole("button", { name: "Серверы", exact: true })).toBeFocused()
})
test("Large server rail: выбранное устройство и граница площадок", async ({ page }) => {
await setup(page, "/probes?server=1000", 1000, true)
const rail = page.locator("aside").filter({ has: page.getByRole("listbox", { name: "Серверы", exact: true }) })
const list = rail.getByRole("listbox", { name: "Серверы", exact: true })
await expect(list.getByRole("option")).toHaveCount(1)
await expect(list.getByRole("option", { selected: true })).toContainText("Источник 1000")
await expect(rail.getByText("Площадка B", { exact: true })).toBeVisible()
await list.getByRole("option", { selected: true }).focus()
await page.keyboard.press("End")
await expect(list.getByRole("option", { selected: true })).toContainText("Цель")
await expect(list.getByRole("option")).toHaveCount(50)
await expect(rail.getByText("Площадка A", { exact: true })).toBeVisible()
await expect(rail.getByText("Площадка B", { exact: true })).toBeVisible()
await page.keyboard.press("Home")
await expect(list.getByRole("option", { selected: true })).toContainText("Источник 1")
await expect(list.getByRole("option")).toHaveCount(50)
})
+38
View File
@@ -0,0 +1,38 @@
import { test, expect, type Page } from "@playwright/test"
import { evoCatalog, evoSettings } from "./fixtures/evobgp"
const community = { id: "copy", value: "64512:7", name: "Копируемое значение", description: "", type: "custom", filterIds: [], serverCount: 0, prefixCount: null, action: "permit", enabled: true }
async function setup(page: Page) {
await page.addInitScript(() => {
const state = window as unknown as { attempts: number; finishCopy: (failed: boolean) => void }
state.attempts = 0
Object.defineProperty(navigator, "clipboard", { value: { writeText: () => { state.attempts++; return new Promise<void>((resolve, reject) => { state.finishCopy = failed => failed ? reject(new Error("Clipboard denied")) : resolve() }) } } })
})
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => { const p = new URL(r.request().url()).pathname; return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/evobgp/settings" ? evoSettings : p === "/api/evobgp/catalog" ? { ...evoCatalog, communities: [community] } : {} }) })
await page.goto("/communities")
return page.getByRole("button", { name: "Скопировать community: 64512:7", exact: true })
}
for (const failed of [false, true]) test("Communities copy: pending/ack/retry " + failed, async ({ page }) => {
const button = await setup(page)
await button.evaluate(el => { (el as HTMLButtonElement).click(); (el as HTMLButtonElement).click() })
await expect(button).toBeDisabled(); await expect(page.getByText("Копирование community…", { exact: true })).toBeVisible()
expect(await page.evaluate(() => (window as unknown as { attempts: number }).attempts)).toBe(1)
await expect(page.getByText("Community 64512:7 скопировано", { exact: true })).toHaveCount(0)
await page.evaluate(f => (window as unknown as { finishCopy: (failed: boolean) => void }).finishCopy(f), failed)
await expect(button).toBeEnabled()
if (failed) {
await expect(page.getByRole("alert").filter({ hasText: "Community не скопировано" })).toBeVisible()
await expect(page.getByText("Community 64512:7 скопировано", { exact: true })).toHaveCount(0)
await button.click(); await page.evaluate(() => (window as unknown as { finishCopy: (failed: boolean) => void }).finishCopy(false))
await expect(page.getByText("Community не скопировано", { exact: true })).toHaveCount(0)
}
await expect(page.getByText("Community 64512:7 скопировано", { exact: true })).toBeVisible()
})
for (const failed of [false, true]) test("Communities copy: late result after unmount " + failed, async ({ page }) => {
const button = await setup(page); await button.click(); await expect(button).toBeDisabled()
await page.locator('a[href="/asns"]').first().click(); await expect(page).toHaveURL(/asns$/)
await page.evaluate(f => (window as unknown as { finishCopy: (failed: boolean) => void }).finishCopy(f), failed)
await page.locator('a[href="/communities"]').first().click(); await expect(button).toBeEnabled()
await expect(page.getByText("Community 64512:7 скопировано", { exact: true })).toHaveCount(0)
await expect(page.getByText("Community не скопировано", { exact: true })).toHaveCount(0)
})
+132
View File
@@ -0,0 +1,132 @@
import { test, expect } from "@playwright/test"
for (const section of ["wireguard", "ipsec", "gre", "firewall", "filters", "recursive-routes"]) {
test(`История ${section}: ошибка сохраняет подтверждение, повтор закрывает`, async ({ page }) => {
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
page.on("console", message => { if (message.type() === "error") errors.push(message.text()) })
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
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.endsWith("/restore")) {
expect(decodeURIComponent(path)).toBe(`/api/${section}/revisions/rev/1/restore`)
expect(route.request().postDataJSON()).toEqual({ serverId: "1" })
attempts++
if (attempts === 1) {
await gate
return route.fulfill({ status: 403, json: { error: "Восстановление запрещено тест" } })
}
if (attempts === 2) return route.fulfill({ json: { ok: false, routes: [], rules: [] } })
if (attempts === 3) return route.fulfill({ json: {} })
return route.fulfill({ json: { ok: true, serverId: 1, pushedRoutes: 0, routes: [], rules: [] } })
}
const json = path.endsWith("/revisions") ? { revisions: [{ id: "rev/1", serverId: "1", section, source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] }
: path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/wireguard" ? { interfaces: [] }
: path === "/api/ipsec" ? { servers: [], clients: [] }
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/firewall/all" ? { rules: [], addressLists: [] }
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
: path === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [], live: true, stale: false }
: path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {}
return route.fulfill({ json })
})
await page.goto(`/${section}?server=1`)
if (section === "ipsec") await page.getByRole("option").filter({ hasText: "История роутер" }).click()
await page.getByRole("button", { name: "История", exact: true }).click()
const sheet = page.getByRole("dialog")
await sheet.getByRole("button", { name: "Откатить", exact: true }).click()
const dialog = page.getByRole("alertdialog")
await dialog.getByRole("button", { name: "Откатить", exact: true }).evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(dialog.getByRole("status")).toContainText("Ожидание результата")
await expect(dialog.getByRole("button", { name: "Отмена" })).toBeDisabled()
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
release!()
await expect(dialog.getByRole("alert").filter({ hasText: "Восстановление запрещено тест" })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Откатить", exact: true })).toBeEnabled()
for (let i = 0; i < 2; i++) {
await dialog.getByRole("button", { name: "Откатить", exact: true }).click()
await expect.poll(() => attempts).toBe(i + 2)
await expect(dialog.getByRole("alert").filter({ hasText: "Сервер не подтвердил восстановление" })).toBeVisible()
await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0)
}
await dialog.getByRole("button", { name: "Откатить", exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(4)
await sheet.getByRole("button", { name: "Откатить", exact: true }).click()
await expect(dialog.getByText("Восстановление запрещено тест")).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
expect(errors.filter(error => !error.includes("403 (Forbidden)"))).toEqual([])
})
}
for (const section of ["ipsec", "gre", "firewall", "filters", "recursive-routes"]) {
test(`История ${section}: ошибка, повтор и поздний ответ закрытой панели`, async ({ page }) => {
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
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/${section}/revisions`) {
attempts++
if (attempts === 1) return route.fulfill({ status: 403, json: { error: "История недоступна тест" } })
if (attempts === 2) {
await gate
return route.fulfill({ status: 500, json: { error: "Устаревшая ошибка тест" } })
}
if (attempts === 3) return route.fulfill({ json: {} })
const revision = { id: "rev/1", serverId: "1", section, source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }
if (attempts === 4) return route.fulfill({ json: { revisions: [{ ...revision, serverId: "2" }] } })
if (attempts === 5) return route.fulfill({ json: { revisions: [{ ...revision, section: "wireguard" }] } })
if (attempts === 6) return route.fulfill({ json: { revisions: [{ ...revision, createdAt: "invalid" }] } })
return route.fulfill({ json: { revisions: [] } })
}
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
: path === "/api/ipsec" ? { servers: [], clients: [] }
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/firewall/all" ? { rules: [], addressLists: [] }
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
: path === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [], live: true, stale: false }
: path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
: path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
})
await page.goto(`/${section}?server=1`)
if (section === "ipsec") await page.getByRole("option").filter({ hasText: "История роутер" }).click()
const open = page.getByRole("button", { name: "История", exact: true })
const sheet = page.getByRole("dialog")
await open.click()
await expect(sheet.getByRole("alert")).toContainText("История недоступна тест")
await expect(sheet.getByText("Истории пока нет", { exact: true })).toHaveCount(0)
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
await expect.poll(() => attempts).toBe(2)
await expect(sheet.getByRole("status")).toContainText("Загрузка истории")
await page.keyboard.press("Escape")
await expect(sheet).toHaveCount(0)
await open.click()
await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории")
for (let attempt = 4; attempt <= 6; attempt++) {
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
await expect.poll(() => attempts).toBe(attempt)
await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории")
await expect(sheet.getByRole("button", { name: "Откатить", exact: true })).toHaveCount(0)
}
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible()
const response = page.waitForResponse(r => r.url().includes(`/api/${section}/revisions?`))
release!()
await (await response).finished()
await page.waitForTimeout(150)
await expect(sheet.getByRole("alert")).toHaveCount(0)
await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible()
})
}
+23
View File
@@ -0,0 +1,23 @@
import {test,expect} from "@playwright/test"
import {container,server,state} from "./fixtures/containers"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
for(const change of ["backend","mode"])for(const fails of [false,true])test("Контейнеры: позднее чтение/"+change+"/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:state()})})
await page.route("**/api/containers/state",async r=>{if(new URL(r.request().url()).port!=="8000")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА контейнеров"}}:{headers,json:state([{...container,name:"СТАРЫЙ КОНТЕЙНЕР"}])})}catch{}finally{completed=true}})
await page.goto("/containers");await expect.poll(()=>started).toBe(true);await page.goto("/settings")
if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click()
await page.goto("/containers");release();await expect.poll(()=>completed).toBe(true)
if(change==="backend")await expect(page.getByText(container.name,{exact:true})).toBeVisible();else await expect(page.getByText("Моковые данные",{exact:true})).toBeVisible()
await expect(page.getByText("СТАРЫЙ КОНТЕЙНЕР",{exact:true})).toHaveCount(0);await expect(page.getByText(/СТАРАЯ ОШИБКА контейнеров/)).toHaveCount(0)
})
for(const action of ["stop","remove"])for(const fails of [false,true])test("Контейнеры: поздняя команда/"+action+"/"+fails,async({page})=>{
const commandContainer={...container,status:action==="remove"?"stopped":"running"}
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:state([commandContainer])})})
await page.route("**/api/servers/1/containers/"+action,async r=>{started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ КОМАНДА ОТКАЗ"}}:{headers,json:{ok:true}})}catch{}finally{completed=true}})
await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name:action==="stop"?"Остановить":"Удалить",exact:true}).click()
if(action==="remove")await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click()
await expect.poll(()=>started).toBe(true);await page.goto("/settings");const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter");await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(true)
await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByText(/СТАРАЯ КОМАНДА ОТКАЗ/)).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toHaveCount(0)
})
+38
View File
@@ -0,0 +1,38 @@
import {test,expect,type Page} from "@playwright/test"
import {container,server,state} from "./fixtures/containers"
async function routes(page:Page) {
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[server]:state()}))
}
test.beforeEach(async({page})=>routes(page))
const refresh=(page:Page)=>page.getByRole("button",{name:"Обновить",exact:true})
for(const kind of ["API","malformed","duplicate","foreign"] as const)test("Контейнеры: первоначальный отказ "+kind,async({page})=>{
let fail=true;await page.route("**/api/containers/state",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"Каталог отказал"}}:{json:kind==="malformed"?{containers:[]}:state(kind==="duplicate"?[container,container]:[{...container,serverId:"2"}])}):{json:state()}))
await page.goto("/containers");await expect(page.getByRole("alert",{name:"Каталог контейнеров",exact:true})).toContainText("Контейнеры пока неизвестны");await expect(page.getByText("Контейнеры не найдены",{exact:true})).toHaveCount(0);await expect(page.getByText(container.name,{exact:true})).toHaveCount(0);await expect(page.getByRole("button",{name:"Новый контейнер",exact:true})).toBeDisabled()
fail=false;await refresh(page).click();await expect(page.getByText(container.name,{exact:true})).toBeVisible()
})
for(const kind of ["API","malformed","duplicate"] as const)test("Контейнеры: независимый отказ серверов "+kind,async({page})=>{
await page.route("**/api/servers",r=>r.fulfill(kind==="API"?{status:403,json:{error:"Серверы отказали"}}:{json:kind==="duplicate"?[server,server]:[{}]}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByRole("alert",{name:"Серверы контейнеров"})).toContainText("Каталог серверов пока неизвестен")
await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled()
})
test("Контейнеры: подтверждённое empty и unsupported",async({page})=>{
let unsupported=false;await page.route("**/api/containers/state",r=>r.fulfill({json:state([],{availability:unsupported?"unsupported":"available"})}));await page.goto("/containers");await expect(page.getByText("Подтверждённый каталог пуст",{exact:true})).toBeVisible();await expect(page.getByText("Список контейнеров пока неизвестен",{exact:true})).toHaveCount(0);unsupported=true;await refresh(page).click();await expect(page.getByRole("status").filter({hasText:"пакет container недоступен"})).toBeVisible()
})
test("Контейнеры: фоновый отказ сохраняет снимок",async({page})=>{
let fail=false;await page.route("**/api/containers/state",r=>r.fulfill(fail?{status:503,json:{error:"ФОНОВЫЙ ОТКАЗ"}}:{json:state()}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();fail=true;await refresh(page).click();await expect(page.getByRole("alert",{name:"Каталог контейнеров",exact:true})).toContainText("Последние данные сохранены");await expect(page.getByText(container.name,{exact:true})).toBeVisible()
})
test("Контейнеры: частичный отказ устройства сохраняет его прошлые строки",async({page})=>{
let fail=false;await page.route("**/api/containers/state",r=>r.fulfill({json:fail?state([],{availability:"error",failures:[{resource:"containers",error:"УСТРОЙСТВО ОТКАЗАЛО"}]}):state()}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();fail=true;await refresh(page).click();await expect(page.getByRole("alert",{name:"Контейнеры сервера 1",exact:true})).toContainText("Последние контейнеры сохранены");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled()
})
test("Контейнеры: неизвестный статус и partial зависимости видимы",async({page})=>{
await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"unknown",startOnBoot:null,image:"",tag:""}],{failures:[{resource:"envs",error:"ENVS ОТКАЗ"}]})}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByText("Неизвестно",{exact:true}).first()).toBeVisible();await expect(page.getByRole("alert",{name:"Контейнеры сервера 1"})).toContainText("ENVS ОТКАЗ")
})
test("Контейнеры: медленный каталог серверов не задерживает контейнеры",async({page})=>{
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/servers",async r=>{await gate;await r.fulfill({json:[server]}).catch(()=>{})});await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();release();await expect(refresh(page)).toBeEnabled()
})
test("Контейнеры: до ответа нет демо и ложного empty",async({page})=>{
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/containers/state",async r=>{await gate;await r.fulfill({json:state()}).catch(()=>{})});await page.goto("/containers");await expect(page.getByRole("status").filter({hasText:"Загрузка контейнеров…"})).toBeVisible();await expect(page.getByText("Контейнеры не найдены",{exact:true})).toHaveCount(0);await expect(page.getByText(container.name,{exact:true})).toHaveCount(0);release();await expect(page.getByText(container.name,{exact:true})).toBeVisible()
})
test("Контейнеры: нет server read — запрос и команды недоступны",async({page})=>{
await page.addInitScript(()=>localStorage.setItem("mmapp_token","header."+btoa(JSON.stringify({sub:"test",apps:["mm"],permissions:["mm:dashboard:read"],exp:Math.floor(Date.now()/1000)+3600}))+".signature"));await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let count=0;await page.route("**/api/servers",r=>{count++;return r.fulfill({json:[server]})});await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByRole("status").filter({hasText:"mm:servers:read"})).toBeVisible();expect(count).toBe(0);await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled()
})
+17
View File
@@ -0,0 +1,17 @@
import {test,expect} from "@playwright/test"
import {container,server,state} from "./fixtures/containers"
for(const [name,width] of [["desktop",1440],["mobile",390],["narrow",320]] as const)test("Контейнеры: клавиатура и адаптивность "+name,async({page})=>{
await page.setViewportSize({width,height:900})
const long={...container,name:"container-with-a-very-long-name-for-narrow-screens",image:"registry.example.test/project/long-container-image",comment:"Длинный комментарий для проверки переноса текста в карточке"}
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[{...server,name:"Очень длинное имя сервера контейнеров"}]:state([long])}))
await page.goto("/containers");await expect(page.getByText(long.name,{exact:true})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth+1)).toBe(true)
const search=page.getByRole("textbox",{name:"Поиск контейнеров"});await search.fill("нет-совпадений");await expect(page.getByText("Измените фильтр или поиск",{exact:true})).toBeVisible();await search.fill("")
const stopped=page.getByRole("group",{name:"Статус контейнеров"}).getByRole("button",{name:"Остановлен",exact:true});await stopped.focus();await page.keyboard.press("Enter");await expect(stopped).toHaveAttribute("aria-pressed","true");const all=page.getByRole("group",{name:"Статус контейнеров"}).getByRole("button",{name:"Все",exact:true});await all.click();await expect(all).toHaveAttribute("aria-pressed","true")
await page.screenshot({path:"docs/ux-ui/screens/containers-overview-"+name+".png",fullPage:true})
const menu=page.getByRole("button",{name:"Действия контейнера "+long.name});await menu.focus();await page.keyboard.press("Enter");await page.getByRole("menuitem",{name:"Снимок JSON",exact:true}).click();const dialog=page.getByRole("dialog");await expect(dialog).toContainText("конфигурация устройства и резервная копия");await expect.poll(async()=>{const bounds=await dialog.boundingBox();return !!bounds&&bounds.x>=0&&bounds.x+bounds.width<=width+1&&(width>=640||bounds.width>=width-2)}).toBe(true);expect(await dialog.evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true)
const download=page.waitForEvent("download");await dialog.getByRole("button",{name:"Файл",exact:true}).click();const file=await download;expect(file.suggestedFilename()).toMatch(/\.json$/);const stream=await file.createReadStream();const chunks:Buffer[]=[];for await(const chunk of stream!)chunks.push(Buffer.from(chunk));const json=JSON.parse(Buffer.concat(chunks).toString("utf8"));expect(json.container.name).toBe(long.name);expect(json.collectedAt).toBe("2026-10-01T00:00:00Z");expect(json.source).toBe("live");expect(JSON.stringify(json)).not.toContain("172.17.0.2")
await page.screenshot({path:"docs/ux-ui/screens/containers-export-"+name+".png",fullPage:true});await dialog.getByRole("button",{name:"Закрыть",exact:true}).first().click();await expect(dialog).toHaveCount(0)
})
test("Контейнеры: неизвестный статус не предлагает запуск",async({page})=>{
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[server]:state([{...container,status:"unknown"}])}));await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Запустить",exact:true})).toBeDisabled();await expect(page.getByRole("menuitem",{name:"Перезапустить",exact:true})).toBeDisabled()
})
+37
View File
@@ -0,0 +1,37 @@
import {test,expect,type Page} from "@playwright/test"
import {container,server,state} from "./fixtures/containers"
async function routes(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[server]:state()}))}
test.beforeEach(async({page}, info)=>{await routes(page); if(info.title.startsWith("Удаление контейнера:")) await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"stopped"}])}))})
async function action(page:Page,name:string){await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name,exact:true}).click()}
for(const status of ["running","unknown","error"] as const)test("Удаление требует остановки: "+status,async({page})=>{
let calls=0;await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status}])}));await page.route("**/api/servers/1/containers/remove",r=>{calls++;return r.fulfill({json:{ok:true}})})
await page.goto("/containers");await action(page,"Удалить");const dialog=page.getByRole("alertdialog");await expect(dialog).toContainText("после подтверждённой остановки");await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeDisabled();expect(calls).toBe(0)
await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"stopped"}])}));await dialog.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeEnabled();expect(calls).toBe(0)
})
for(const kind of ["403","502","malformed"] as const)test("Удаление контейнера: ошибка сохраняет target "+kind,async({page})=>{
let calls=0;await page.route("**/api/servers/1/containers/remove",r=>{calls++;return r.fulfill(kind==="malformed"?{json:{ok:false}}:{status:Number(kind),json:{error:"УДАЛЕНИЕ ОТКАЗ"}})})
await page.goto("/containers");await action(page,"Удалить");const dialog=page.getByRole("alertdialog");await dialog.getByRole("button",{name:"Удалить",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Удаление контейнера"})).toBeVisible();await expect(dialog).toContainText(container.name);expect(calls).toBe(1)
if(kind==="403"){await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeEnabled()}else{await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeDisabled();await expect(dialog).toContainText("Результат команды не подтверждён");await dialog.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeEnabled()}
})
test("Удаление контейнера: lock, Escape, один запрос и refresh failure",async({page})=>{
let release!:()=>void,calls=0,removed=false;const gate=new Promise<void>(r=>release=r)
await page.route("**/api/containers/state",r=>r.fulfill(removed?{status:503,json:{error:"READ AFTER DELETE FAIL"}}:{json:state([{...container,status:"stopped"}])}));await page.route("**/api/servers/1/containers/remove",async r=>{calls++;await gate;removed=true;await r.fulfill({json:{ok:true}})})
await page.goto("/containers");await action(page,"Удалить");const dialog=page.getByRole("alertdialog");const remove=dialog.getByRole("button",{name:"Удалить",exact:true});await remove.click();await expect(dialog.getByRole("button",{name:"Удаление…",exact:true})).toBeDisabled();await expect(dialog.getByRole("button",{name:"Отмена",exact:true})).toBeDisabled();await page.keyboard.press("Escape");await expect(dialog).toBeVisible();expect(calls).toBe(1);release();await expect(dialog).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toContainText("Обновление каталогов не удалось");await expect(page.getByText(container.name,{exact:true})).toHaveCount(0)
})
for(const [name,path] of [["Остановить","stop"],["Запустить","start"],["Перезапустить","restart"]] as const)test("Команда контейнера: подтверждение отдельно от состояния "+path,async({page})=>{
const initial={...container,status:path==="start"?"stopped":"running"}
await page.route("**/api/containers/state",r=>r.fulfill({json:state([initial])}));let payload:unknown
await page.route("**/api/servers/1/containers/"+path,r=>{payload=r.request().postDataJSON();return r.fulfill({json:{ok:true}})});await page.goto("/containers");await action(page,name);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toBeVisible();expect(payload).toEqual({rosId:"*A"});await expect(page.getByText(path==="start"?"Остановлен":"Работает",{exact:true}).first()).toBeVisible()
})
for(const bad of [{ok:false},{},{ok:"true"}])test("Команда контейнера: неверное подтверждение "+JSON.stringify(bad),async({page})=>{
await page.route("**/api/servers/1/containers/stop",r=>r.fulfill({json:bad}));await page.goto("/containers");await action(page,"Остановить");await expect(page.getByRole("alert",{name:"Команда контейнера"})).toContainText("Результат команды не подтверждён");await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toHaveCount(0);await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled()
})
test("Команда контейнера: общий lock блокирует другие карточки",async({page})=>{
let release!:()=>void,calls=0;const gate=new Promise<void>(r=>release=r);const other={...container,id:"1-*B",rosId:"*B",name:"container-other"}
await page.route("**/api/containers/state",r=>r.fulfill({json:state([container,other])}));await page.route("**/api/servers/1/containers/stop",async r=>{calls++;await gate;await r.fulfill({json:{ok:true}})})
await page.goto("/containers");await action(page,"Остановить");await expect(page.getByRole("button",{name:"Действия контейнера "+other.name})).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeDisabled();expect(calls).toBe(1);release();await expect(page.getByRole("button",{name:"Действия контейнера "+other.name})).toBeEnabled()
})
test("Удаление контейнера: исчезнувший target не отправляется повторно",async({page})=>{
let exists=true;await page.route("**/api/containers/state",r=>r.fulfill({json:state(exists?[{...container,status:"stopped"}]:[])}));await page.route("**/api/servers/1/containers/remove",r=>{exists=false;return r.fulfill({status:502,json:{error:"Связь потеряна"}})})
await page.goto("/containers");await action(page,"Удалить");const dialog=page.getByRole("alertdialog");await dialog.getByRole("button",{name:"Удалить",exact:true}).click();await dialog.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeDisabled()
})
+24
View File
@@ -0,0 +1,24 @@
import {test,expect,type Page} from "@playwright/test"
import {container,server,state} from "./fixtures/containers"
test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[server]:state()}))})
for(const query of ["server=bad","server=1&server=2","status=bad","q=a&q=b"])test("Контейнеры URL: ошибочный контекст "+query,async({page})=>{
await page.goto("/containers?"+query+"&keep=map#detail");await expect(page.getByRole("alert",{name:"Контекст контейнеров",exact:true})).toBeVisible();await page.getByRole("button",{name:"Сбросить контекст",exact:true}).click();await expect(page).toHaveURL(/containers\?keep=map#detail$/);await expect(page.getByText(container.name,{exact:true})).toBeVisible()
})
test("Контейнеры URL: фильтр, reload и Back сохраняют контекст",async({page})=>{
await page.goto("/containers?server=1&status=running&q=container&keep=map#detail");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByRole("textbox",{name:"Поиск контейнеров"})).toHaveValue("container");const group=page.getByRole("group",{name:"Статус контейнеров"});await group.getByRole("button",{name:"Остановлен",exact:true}).click();await expect(page).toHaveURL(/status=stopped/);await expect(page.getByText("Измените фильтр или поиск",{exact:true})).toBeVisible();await page.goBack();await expect(page.getByText(container.name,{exact:true})).toBeVisible();await page.reload();await expect(page.getByRole("textbox",{name:"Поиск контейнеров"})).toHaveValue("container");await expect(group.getByRole("button",{name:"Работает",exact:true})).toHaveAttribute("aria-pressed","true");await expect(page).toHaveURL(/keep=map#detail$/)
})
test("Контейнеры URL: недоступный сервер не заменяется всеми",async({page})=>{
await page.goto("/containers?server=2&keep=map");await expect(page.getByRole("alert").filter({hasText:"Выбранный сервер недоступен"})).toBeVisible();await expect(page.getByText(container.name,{exact:true})).toHaveCount(0);await expect(page).toHaveURL(/server=2/)
})
async function openExport(page:Page){await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name:"Снимок JSON",exact:true}).click();return page.getByRole("dialog")}
for(const absent of [false,true])test("Контейнеры: Clipboard отказ "+absent,async({page})=>{
await page.addInitScript(absent=>{Object.defineProperty(navigator,"clipboard",{configurable:true,value:absent?undefined:{writeText:async()=>{throw new Error("denied")}}})},absent)
const dialog=await openExport(page);await dialog.getByRole("button",{name:"Копировать",exact:true}).click();await expect(page.getByText("Не удалось скопировать. Повторите действие или скачайте файл.",{exact:true})).toBeVisible();await expect(dialog.getByRole("button",{name:"Скопировано",exact:true})).toHaveCount(0);await expect(dialog.getByRole("button",{name:"Файл",exact:true})).toBeEnabled()
})
test("Контейнеры: успешный Clipboard и возврат фокуса",async({page})=>{
await page.addInitScript(()=>{Object.defineProperty(navigator,"clipboard",{configurable:true,value:{writeText:async(value:string)=>{(window as unknown as {copied:string}).copied=value}}})})
const dialog=await openExport(page);await dialog.getByRole("button",{name:"Копировать",exact:true}).click();await expect(dialog.getByRole("button",{name:"Скопировано",exact:true})).toBeVisible();expect(await page.evaluate(()=>(window as unknown as {copied:string}).copied)).toContain(container.name);await dialog.getByRole("button",{name:"Закрыть",exact:true}).first().click();await expect(page.getByRole("button",{name:"Действия контейнера "+container.name})).toBeFocused()
})
test("Контейнеры: отмена удаления возвращает фокус",async({page})=>{
await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name:"Удалить",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true}).click();await expect(page.getByRole("button",{name:"Действия контейнера "+container.name})).toBeFocused()
})
+275
View File
@@ -0,0 +1,275 @@
import { test, expect, type Page } from "@playwright/test"
import { serverReadSchema } from "../../packages/contracts/src/servers"
import {dashboardGre,dashboardVxlan,dashboardWg} from "./fixtures/dashboard-overlays"
import {bgpSession} from "./fixtures/bgp"
async function setup(page: Page, initiallyFailed = false, serverCount = 1, initialBgpPayload?:unknown) {
const control = { pathPayload:undefined as unknown,filterPayload:undefined as unknown,speedPayload:undefined as unknown, failed: initiallyFailed, partial: false, prefixes: 321 as number|null, grePayload:undefined as unknown,wgPayload:undefined as unknown,vxlanPayload:undefined as unknown,serversPayload:undefined as unknown,probesPayload:undefined as unknown, 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 = { collectionComplete:true, 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" ? control.serversPayload !== undefined ? control.serversPayload : servers
: path === "/api/uptime/overview" ? control.probesPayload !== undefined ? control.probesPayload : { probes: [] }
: path === "/api/bgp/catalog" ? control.bgpPayload !== undefined ? control.bgpPayload : {sessions:[bgpSession(1,prefixes)],failures:[]}
: path === "/api/filters/rules" ? control.filterPayload!==undefined?control.filterPayload:{ rulesets: [] }
: path === "/api/filters/gre-tunnels" ? control.grePayload!==undefined?control.grePayload:{ tunnels: [dashboardGre],failures:[] }
: path === "/api/wireguard" ? control.wgPayload!==undefined?control.wgPayload:{ interfaces: [],failures:[] }
: path === "/api/vxlan" ? control.vxlanPayload!==undefined?control.vxlanPayload:{ tunnels: [],failures:[] }
: path === "/api/traffic/servers" ? control.trafficPayload !== undefined ? control.trafficPayload : { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500],observations:{rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-09-20T00:00:00Z"} }] }
: path === "/api/internet-path/latest" ? control.pathPayload!==undefined?control.pathPayload:{ snapshot: control.fallback ? null : snapshot }
: path === "/api/uptime/speed-probes" ? control.speedPayload!==undefined?control.speedPayload:{ 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(2);const receivedAt=await source.locator("time").first().getAttribute("datetime");const rate={id:"1",rxNow:1000,txNow:500,rxSeries:[1000],txSeries:[500],observations:{rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-09-20T00:00:00Z"}};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").first()).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();
})
for(const state of ["legacy","unobserved","measured-zero","reset-rx","invalid-time","invalid-mask"])test("Dashboard traffic observation metadata/"+state,async({page})=>{
const control=await setup(page);const source=page.locator('[data-source="traffic"]');await expect(refresh(page)).toBeEnabled();await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();const rate={id:"1",rxNow:0,txNow:0,rxSeries:[0],txSeries:[0]};const observations={rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-10-03T00:00:00Z"};control.trafficPayload={servers:[state==="legacy"?rate:{...rate,observations:state==="unobserved"?{rxNow:false,txNow:false,rxSeries:[false],txSeries:[false],sampledAt:null}:state==="reset-rx"?{...observations,rxNow:false,rxSeries:[false]}:state==="invalid-time"?{...observations,sampledAt:"invalid"}:state==="invalid-mask"?{...observations,rxSeries:[]}:observations}]};await refresh(page).click();await expect(refresh(page)).toBeEnabled();
if(state.startsWith("invalid")){await expect(source).toContainText("ошибка обновления");await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();return}
await expect(source).not.toContainText("ошибка");if(state==="measured-zero"){await expect(page.getByText("RX 0 Кбит/с",{exact:true})).toBeVisible();await expect(page.getByRole("region",{name:"История RX/TX",exact:true})).toBeVisible();await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toHaveCount(0)}else{await expect(page.getByText("RX —",{exact:true})).toBeVisible();if(state==="reset-rx"){await expect(page.getByText("TX 0 Кбит/с",{exact:true})).toBeVisible();await expect(page.getByText("Нет измерений RX за выбранный период",{exact:true})).toBeVisible()}else{await expect(page.getByText("TX —",{exact:true})).toBeVisible();await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible()}}
if(state==="legacy")await expect(page.getByText("Измерения не подтверждены API",{exact:true})).toBeVisible()
})
for(const kind of ["servers","probes"])for(const broken of ["missing","duplicate","numeric"])test("Dashboard strict catalog keeps source cache/"+kind+"/"+broken,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="'+kind+'"]');const receivedAt=await source.locator("time").getAttribute("datetime");const server={id:1,name:"Узел",host:"192.0.2.1",site:"TEST",country:"RU",asn:"64512",type:"exit-node",enabled:true,status:"online",latency:1,os:"7",model:"CHR",sessions:0};const probe={id:"p1",srcServerId:"1",name:"Тест",target:"192.0.2.2",filter:"main",rtt:1,loss:0,status:"up",series:[1],enabled:true,showOnDashboard:true};
if(kind==="servers")control.serversPayload=broken==="missing"?{}:broken==="duplicate"?[server,server]:[{...server,latency:-1}];else control.probesPayload=broken==="missing"?{}:broken==="duplicate"?{probes:[probe,probe]}:{probes:[{...probe,loss:101}]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);if(kind==="servers")await expect(page.getByRole("link").filter({hasText:"Серверы онлайн"})).toContainText("1 / 1");else await expect(page.getByText("Проверки без ошибок",{exact:true})).toHaveCount(0);
control.serversPayload=undefined;control.probesPayload=undefined;await refresh(page).click();await expect(source).not.toContainText("ошибка");
})
test("Dashboard unknown server status is not reported offline or healthy",async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();control.serversPayload=[{id:1,name:"Неопрошенный узел",host:"192.0.2.1",site:"TEST",country:"RU",asn:"64512",type:"exit-node",enabled:true,status:null,latency:null,os:null,model:null,sessions:0}];await refresh(page).click();const tile=page.getByRole("link").filter({hasText:"Серверы онлайн"});await expect(tile).toContainText("Статус неизвестен: 1");await expect(tile).not.toContainText("не в сети");await expect(page.getByRole("region",{name:"Требует внимания",exact:true}).getByRole("link",{name:/Неопрошенный узел/})).toContainText("Статус неизвестен");await expect(page.getByText("Серверы без проблем",{exact:true})).toHaveCount(0);await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");
})
for(const state of ["legacy","unmeasured","zero","failure","gaps","invalid"])test("Dashboard ping distinguishes observed and missing measurements/"+state,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const probe={id:"ping-observation",srcServerId:"1",name:"Измерение ping",target:"192.0.2.2",filter:"main",rtt:0,loss:0,status:"up",series:[0],enabled:true,showOnDashboard:true};const sample=(minute:number,rtt:number|null,status="up",loss=0)=>({sampledAt:"2026-10-03T00:"+String(minute).padStart(2,"0")+":00Z",rtt,status,loss});const samples=state==="unmeasured"?[]:state==="failure"?[sample(30,null,"down",100)]:state==="gaps"?[sample(29,5),sample(30,6),sample(32,0)]:state==="invalid"?[sample(30,0),sample(30,0)]:[sample(30,0)];control.probesPayload=state==="legacy"?{probes:[probe]}:{rangeStartAt:"2026-10-03T00:00:00Z",rangeEndAt:"2026-10-03T01:00:00Z",probes:[{...probe,observations:{samples}}]};await refresh(page).click();await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="probes"]');if(state==="invalid"){await expect(source).toContainText("ошибка обновления");return}await expect(source).not.toContainText("ошибка");const row=page.locator('tr[data-row-id]').filter({hasText:"Измерение ping"});await expect(row).toHaveCount(1);
if(state==="legacy"||state==="unmeasured"){await expect(row).toContainText("Неизвестно");await expect(page.getByRole("link").filter({hasText:"Проверки с проблемами"})).toContainText("неизвестны: 1");await expect(page.getByText("Проверки без ошибок",{exact:true})).toHaveCount(0);await expect(row).not.toContainText("100%");await expect(page.locator('[data-latency-source]')).toHaveCount(0)}else if(state==="failure"){await expect(row).toContainText("Оффлайн");await expect(row).toContainText("100%");await expect(page.getByRole("link").filter({hasText:"Проверки с проблемами"})).toContainText("1 ошибок")}else{await expect(row).toContainText("Онлайн");await expect(row).toContainText("0%");await expect(page.locator('[data-latency-isolated]')).toHaveCount(1);if(state==="gaps"){await expect(page.locator('.recharts-line-curve')).toHaveAttribute("d",/M.*M/);for(const width of [1440,320]){await page.setViewportSize({width,height:900});await page.getByRole("region",{name:"История RTT по источникам",exact:true}).scrollIntoViewIfNeeded();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);await page.screenshot({path:"docs/ux-ui/screens/dashboard-observed-ping-"+width+".png",animations:"disabled"})}}else await expect(page.locator('[data-latency-segment]')).toHaveCount(0);await expect(source.locator("time").last()).toHaveAttribute("datetime",new Date(samples.at(-1)!.sampledAt).toISOString())}
})
for(const kind of ["gre","wg","vxlan"] as const)for(const broken of ["missing","partial","duplicate"])test("Dashboard overlay complete source and cache/"+kind+"/"+broken,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="'+kind+'"]'),controlKey=kind==="gre"?"grePayload":kind==="wg"?"wgPayload":"vxlanPayload",rowsKey=kind==="wg"?"interfaces":"tunnels",row=kind==="gre"?dashboardGre:kind==="wg"?dashboardWg:dashboardVxlan;control[controlKey]={[rowsKey]:[row],failures:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");const receivedAt=await source.locator("time").getAttribute("datetime");control[controlKey]=broken==="missing"?{}:broken==="partial"?{[rowsKey]:[row],failures:[{serverId:"2",serverName:"Узел без ответа",error:"timeout"}]}:{[rowsKey]:[row,row],failures:[]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);await expect(page.getByRole("link").filter({hasText:"Туннели работают"})).toContainText("Последний успешный снимок");await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");if(kind==="gre")await expect(page.getByText("GRE сохранён",{exact:true})).toBeVisible();control[controlKey]={[rowsKey]:[],failures:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");if(kind==="gre")await expect(page.getByText("GRE сохранён",{exact:true})).toHaveCount(0)
})
for(const broken of ["missing","incomplete","foreign","invalid-date","corrupt-null","legacy","filters","speed"])test("Dashboard Internet path keeps last confirmed source/"+broken,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="internetPath"]');await expect(source).not.toContainText("ошибка");const times=await source.locator("time").evaluateAll(nodes=>nodes.map(n=>n.getAttribute("datetime")));
if(broken==="filters"||broken==="speed"){control.fallback=true;if(broken==="filters")control.filterPayload={rulesets:[],stale:true};else control.speedPayload={}}
else control.pathPayload=broken==="missing"?{}:broken==="corrupt-null"?{snapshot:null,sampledAt:"2026-09-20T00:00:00Z"}:{snapshot:{collectionComplete:broken==="legacy"?undefined:true,sampledAt:broken==="invalid-date"?"invalid":"2026-09-20T00:00:00Z",servers:[],greTunnels:[],filtersRulesets:[],speedProbes:broken==="incomplete"?undefined:[],routeLookupByServerId:broken==="foreign"?{"99":null}:{},wanRuntimeByHomeId:{}}};
await refresh(page).click();await expect(source).toContainText("ошибка обновления");expect(await source.locator("time").evaluateAll(nodes=>nodes.map(n=>n.getAttribute("datetime")))).toEqual(times);await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");
control.pathPayload=undefined;control.filterPayload=undefined;control.speedPayload=undefined;control.fallback=false;await refresh(page).click();await expect(source).not.toContainText("ошибка");
})
for(const state of ["unmeasured","measured","failed","unknown"])test("Dashboard Internet path hop observations/"+state,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const at="2026-10-04T00:00:00Z";
const nodes=["home-router","jump-host","exit-node"].map((type,i)=>({id:i+1,name:"Path node "+(i+1),host:"192.0.2."+(i+1),site:"TEST",country:"RU",asn:"64512",type,enabled:true,status:state==="unknown"&&i===0?null:"online",latency:99,os:"7",model:"CHR",sessions:0,wanUplinks:i===0?[{id:"wan1",name:"WAN",isp:"Test ISP",iface:"ether1",ip:"192.0.2.10",maxDl:999,maxUl:888}]:[]}));
const probe={id:"hop1",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0,lastRxAvgMbps:5,lastPingAt:state==="unmeasured"?null:at,lastRunAt:state==="unmeasured"?null:at,lastStatus:state==="failed"?"error":"done",lastError:state==="failed"?"denied":null,lastPingError:state==="failed"?"denied":null};
control.pathPayload={snapshot:{collectionComplete:true,sampledAt:at,servers:nodes,greTunnels:[],filtersRulesets:[],speedProbes:[probe],routeLookupByServerId:{"1":{gateway:"192.0.2.254",routingMark:null}},wanRuntimeByHomeId:{"1":{defaultGateway:"192.0.2.254",defaultInterface:"ether1",uplinks:[{id:"wan1",name:"WAN",isp:"Test ISP",iface:"ether1",configuredIp:"192.0.2.10",leasedIp:"192.0.2.10",dhcpStatus:"bound",isDefault:true}]}}}};
await refresh(page).click();await expect(refresh(page)).toBeEnabled();await expect(page.locator('[data-source="internetPath"]')).not.toContainText("ошибка");const strip=page.getByText("JH/Exit — схема кандидатов. Default route подтверждает только WAN. Метрики — последние подтверждённые пробы участков.",{exact:true}).locator("..");await expect(strip).toBeVisible();await expect(strip).not.toContainText("999");await expect(strip).not.toContainText("99 мс");
if(state==="measured"||state==="unknown"){await expect(strip.getByText("0 мс",{exact:true})).toBeVisible();await expect(strip.getByText("5 ↓ Мбит/с",{exact:true})).toBeVisible()}else{await expect(strip.getByText("0 мс",{exact:true})).toHaveCount(0);await expect(strip.getByText("5 ↓ Мбит/с",{exact:true})).toHaveCount(0)}
if(state==="unknown"){await expect(strip.getByText("Неизвестно",{exact:true})).toBeVisible();await expect(strip.getByText("Оффлайн",{exact:true})).toHaveCount(0)}
await page.setViewportSize({width:320,height:900});await expect(strip).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
})
test("Dashboard large attention queue mounts rows on demand",async({page},info)=>{
test.setTimeout(90000);const start=Date.now();await setup(page,false,1000);await expect(refresh(page)).toBeEnabled();const queue=page.getByRole("region",{name:"Требует внимания",exact:true});const links=queue.locator('a[href^="/servers?server="]');await expect(links).toHaveCount(5);const sample={fixtureRows:1000,readyMs:Date.now()-start,renderedAttentionRows:await links.count(),domNodes:await page.locator("*").count()};console.log(JSON.stringify(sample));await info.attach("large-attention-metrics",{body:JSON.stringify(sample),contentType:"application/json"});const summary=queue.locator("summary").filter({hasText:"Ещё 995"});await summary.focus();await summary.press("Enter");await expect(links).toHaveCount(55);await queue.getByRole("button",{name:"Показать ещё 50",exact:true}).click();await expect(links).toHaveCount(105);await summary.focus();await summary.press("Enter");await expect(links).toHaveCount(5);await summary.press("Enter");await expect(links).toHaveCount(55);await page.setViewportSize({width:320,height:900});expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);await links.first().click();await expect(page).toHaveURL(/servers\?server=1$/);await expect(page.getByRole("status",{name:"Сервер по ссылке"})).toContainText("Узел 1")
})
test("Dashboard active probes retain page after refreshed measurements",async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const at="2026-10-04T00:00:00Z";const probes=Array.from({length:100},(_,i)=>({id:"large-p"+(i+1),srcServerId:"1",name:"Paged probe "+String(i+1).padStart(3,"0"),target:"192.0.2.2",filter:"main",rtt:0,loss:0,status:"up",series:[],enabled:true,showOnDashboard:true,observations:{samples:[{sampledAt:at,rtt:0,loss:0,status:"up"}]}}));control.probesPayload={rangeStartAt:"2026-10-03T23:00:00Z",rangeEndAt:at,probes};await refresh(page).click();await expect(page.locator("tr[data-row-id]")).toHaveCount(50);await page.getByRole("button",{name:"Следующая страница",exact:true}).click();await expect(page.getByText("Paged probe 051",{exact:true})).toBeVisible();await refresh(page).click();await expect(refresh(page)).toBeEnabled();await expect(page.getByText("51–100 из 100",{exact:true})).toBeVisible();await expect(page.getByText("Paged probe 051",{exact:true})).toBeVisible();control.probesPayload={rangeStartAt:"2026-10-03T23:00:00Z",rangeEndAt:at,probes:probes.slice(0,1)};await refresh(page).click();await expect(page.locator("tr[data-row-id]")).toHaveCount(1);await expect(page.getByText("Paged probe 001",{exact:true})).toBeVisible()
})
+7
View File
@@ -0,0 +1,7 @@
import {test,expect} from "@playwright/test"
import {bgpSession} from "./fixtures/bgp"
for(const bgp of [false,true])test("Dashboard limited JWT requests only authorized data sources/"+bgp,async({page})=>{
const permissions=["mm:dashboard:read",...(bgp?["mm:bgp:read"]:[])],token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions,exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture"
await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);const calls:string[]=[]
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:true}});calls.push(path);return r.fulfill({json:path==="/api/vxlan"?{tunnels:[],failures:[]}:path==="/api/events"?{events:[]}:path==="/api/bgp/catalog"?{sessions:[bgpSession()],failures:[]}:{}})});await page.goto("/dashboard");const refresh=page.getByRole("button",{name:"Обновить данные",exact:true});await expect(refresh).toBeEnabled();await expect(page.locator('[data-source="servers"]')).toContainText("Нет доступа");await refresh.click();await expect(refresh).toBeEnabled();for(const path of ["/api/servers","/api/uptime/overview","/api/filters/rules","/api/filters/gre-tunnels","/api/internet-path/latest","/api/wireguard","/api/traffic/servers"])expect(calls).not.toContain(path);expect(calls.includes("/api/bgp/catalog")).toBe(bgp);const tile=page.getByRole("link").filter({hasText:"Префиксы BGP"});await expect(tile).toContainText(bgp?"321":"—");expect(calls).toContain("/api/vxlan")
})
+35
View File
@@ -0,0 +1,35 @@
import { test, expect, type Page } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
const common = { enabled: true, intervalSec: 45, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
const job = { jobKey: "backups", enabled: true, intervalSec: 60, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null }
const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-03T00:00:00Z", due: true, created: 1, failures: 0, pruned: 0 }
async function setup(page: Page) {
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ headers, json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ headers, json: geoip })
if (path === "/api/scheduler/status") return r.fulfill({ headers, json: { jobs: [job] } })
if (path === "/api/scheduler/runs") return r.fulfill({ headers, json: { runs: [] } })
if (path === "/api/traffic/settings") return r.fulfill({ headers, json: { ...common, intervalSec: new URL(r.request().url()).port === "8101" ? 77 : 45 } })
return r.fulfill({ headers, status: 503, json: { error: "outside context fixture" } })
})
}
for (const action of ["read", "put", "run"] as const) for (const fails of [false, true]) test("Сбор данных: поздний ответ после смены backend/" + action + "/" + fails, async ({ page }) => {
await setup(page); let started = false, completed = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
const path = action === "run" ? "/api/scheduler/jobs/backups/run-now" : "/api/traffic/settings"
await page.route("**" + path, async r => {
if (new URL(r.request().url()).port !== "8000" || (action === "read" ? r.request().method() !== "GET" : action === "put" ? r.request().method() !== "PUT" : false)) return r.fallback()
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРАЯ ОШИБКА" } } : { headers, json: action === "run" ? { ok: true, result: { status: "ok", error: null, result: snapshot }, status: { jobs: [job] } } : action === "put" ? { ok: true, settings: { ...common, intervalSec: 99 } } : { ...common, intervalSec: 99 } }) } catch {} finally { completed = true }
})
try {
await page.goto("/data-collection")
if (action === "put") { const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99"); await page.getByRole("button", { name: "Сохранить настройки планировщика" }).click() }
if (action === "run") { const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click() }
await expect.poll(() => started).toBe(true); await page.goto("/settings"); const url = page.getByPlaceholder("http://localhost:8000"); await url.fill("http://localhost:8101"); await url.press("Enter"); await page.goto("/data-collection")
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("77"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("77"); await expect(page.getByText(/СТАРАЯ ОШИБКА/)).toHaveCount(0); await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toHaveCount(0); await expect(page.getByText("Запуск подтверждён: Бэкапы", { exact: true })).toHaveCount(0)
} finally { release() }
})
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
for (const invalid of [true, false]) test("GeoIP: malformed reader не обрушает сбор данных/" + invalid, async ({ page }) => {
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ json: invalid ? {} : geoip })
if (path === "/api/traffic/flow/settings") return r.fulfill({ status: 503, json: { error: "NetFlow outside fixture" } })
if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
return r.fulfill({ json: { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } })
})
await page.goto("/data-collection")
await expect(page.getByText("Журнал прогонов", { exact: true })).toBeVisible()
if (invalid) await expect(page.getByText("Данные GeoIP не подтверждены: получен некорректный ответ сервера", { exact: true }).first()).toBeVisible()
else await expect(page.getByRole("button", { name: "Сохранить GeoIP", exact: true })).toBeVisible()
expect(errors).toEqual([])
})
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const row = { id: "confirmed-run", jobKey: "backups", startedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:00:00Z", status: "ok", error: null, durationMs: 100, resultJson: null }
async function setup(page: import("@playwright/test").Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ json: geoip })
if (path === "/api/traffic/flow/settings") return r.fulfill({ status: 503, json: { error: "NetFlow outside fixture" } })
return r.fulfill({ json: { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } })
})
}
for (const malformed of [true, false]) test("Журнал: первоначальная ошибка не становится empty/" + malformed, async ({ page }) => {
await setup(page); await page.route("**/api/scheduler/runs?**", r => r.fulfill(malformed ? { json: {} } : { status: 503, json: { error: "history unavailable" } }))
await page.goto("/data-collection"); await expect(page.getByText("История не получена", { exact: true }).first()).toBeVisible()
await expect(page.getByText("Пока нет прогонов", { exact: true })).toHaveCount(0)
await expect(page.getByText("Журнал (в списке)", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("—")
})
test("Журнал: background error сохраняет строки и время последнего чтения", async ({ page }) => {
await setup(page); let fails = false
await page.route("**/api/scheduler/runs?**", r => r.fulfill(fails ? { status: 503, json: { error: "history refresh unavailable" } } : { json: { runs: [row] } }))
await page.goto("/data-collection"); await page.getByRole("button", { name: /Бэкапы.*100 мс/ }).click(); await expect(page.getByText("confirmed-run", { exact: true })).toBeVisible()
const received = await page.getByText(/^История получена:/).textContent(); fails = true
await page.getByRole("button", { name: "Обновить", exact: true }).click(); await expect(page.getByText("История не обновлена", { exact: true })).toBeVisible()
await expect(page.getByText("confirmed-run", { exact: true })).toBeVisible(); await expect(page.getByText(/^История получена:/)).toHaveText(received!)
})
test("Журнал: подтверждённый пустой список имеет ноль и время получения", async ({ page }) => {
await setup(page); await page.route("**/api/scheduler/runs?**", r => r.fulfill({ json: { runs: [] } }))
await page.goto("/data-collection"); await expect(page.getByText("Пока нет прогонов", { exact: true })).toBeVisible(); await expect(page.getByText(/^История получена:/)).toBeVisible()
await expect(page.getByText("Журнал (в списке)", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("0")
})
test("Журнал: смена среза не выдаёт прежние строки за новый фильтр", async ({ page }) => {
await setup(page); await page.route("**/api/scheduler/runs?**", r => r.fulfill(new URL(r.request().url()).searchParams.get("jobKey") === "traffic" ? { status: 503, json: { error: "traffic history unavailable" } } : { json: { runs: [row] } }))
await page.goto("/data-collection"); await expect(page.getByRole("button", { name: /Бэкапы.*100 мс/ })).toBeVisible()
await page.getByLabel("Задача", { exact: true }).selectOption("traffic"); await expect(page.getByText("История не получена", { exact: true }).first()).toBeVisible()
await expect(page.getByRole("button", { name: /Бэкапы.*100 мс/ })).toHaveCount(0); await expect(page.getByText(/^История получена:/)).toHaveCount(0)
})
@@ -0,0 +1,55 @@
import { test, expect, type Page } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const job = { jobKey: "backups", enabled: true, intervalSec: 60, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null }
const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-03T00:00:00Z", due: true, created: 1, failures: 0, pruned: 0 }
const ack = { ok: true, result: { status: "ok", error: null, result: snapshot }, status: { jobs: [job] } }
async function setup(page: Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ json: geoip })
if (path === "/api/scheduler/status") return r.fulfill({ json: { jobs: [job] } })
if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
if (path === "/api/traffic/settings") return r.fulfill({ json: { enabled: true, intervalSec: 45, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null } })
return r.fulfill({ status: 503, json: { error: "outside POST fixture" } })
})
}
for (const [label, result, message] of [
["успех", ack, "Запуск подтверждён: Бэкапы"],
["пропуск", { ...ack, result: { ...ack.result, result: { ...snapshot, due: false, created: 0 } } }, "Прогон пропущен: Бэкапы"],
["частичный сбой", { ...ack, ok: false, result: { status: "error", error: "S3 unavailable", eventError: "event unavailable", result: { ...snapshot, failures: 1 } }, status: null, statusError: "status unavailable" }, "Запуск завершился с ошибками: Бэкапы"],
] as const) test("Ручной запуск: " + label + " отдельно от черновика", async ({ page }) => {
await setup(page); let posts = 0, puts = 0; page.on("request", r => { if (r.method() === "PUT") puts++ })
await page.route("**/api/scheduler/jobs/backups/run-now", r => { posts++; return r.fulfill({ json: result }) })
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99")
const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click(); await expect(page.getByText(message, { exact: true })).toBeVisible(); await expect(field).toHaveValue("99"); expect(posts).toBe(1); expect(puts).toBe(0)
if (label === "частичный сбой") { await expect(page.getByText(/Журнал событий: event unavailable/)).toBeVisible(); await expect(page.getByText("Статус планировщика не обновлён", { exact: true })).toBeVisible() }
})
for (const response of [{}, { ...ack, result: { ...ack.result, result: { v: 1, job: "traffic", sampledAt: snapshot.sampledAt, servers: [] } } }]) test("Ручной запуск: неверное подтверждение не повторяет POST/" + JSON.stringify(response), async ({ page }) => {
await setup(page); let posts = 0; await page.route("**/api/scheduler/jobs/backups/run-now", r => { posts++; return r.fulfill({ json: response }) })
await page.goto("/data-collection"); const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click()
await expect(page.getByText(/Результат операции неизвестен/)).toBeVisible(); await expect(page.getByText("Запуск подтверждён: Бэкапы", { exact: true })).toHaveCount(0); expect(posts).toBe(1)
})
test("Ручной запуск: общий lock блокирует повтор и сохранение", async ({ page }) => {
await setup(page); let posts = 0, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/scheduler/jobs/backups/run-now", async r => { posts++; await gate; await r.fulfill({ json: ack }) })
try {
await page.goto("/data-collection"); const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click(); await expect.poll(() => posts).toBe(1); await expect(run).toBeDisabled(); await expect(page.getByRole("button", { name: "Перечитать таймеры", exact: true })).toBeDisabled(); release(); await expect(page.getByText("Запуск подтверждён: Бэкапы", { exact: true })).toBeVisible(); expect(posts).toBe(1)
} finally { release() }
})
for (const valid of [false, true]) test("Таймеры: строгий receipt/" + valid, async ({ page }) => {
await setup(page); let posts = 0; await page.route("**/api/scheduler/refresh", r => { posts++; return r.fulfill({ json: valid ? { ok: true, status: { jobs: [] } } : { ok: true } }) })
await page.goto("/data-collection"); const refresh = page.getByRole("button", { name: "Перечитать таймеры", exact: true }); await expect(refresh).toBeEnabled(); await refresh.click()
await expect(page.getByText(valid ? "Перечитывание таймеров подтверждено сервером" : "Ответ планировщика не подтверждён: получены некорректные данные", { exact: true })).toBeVisible(); expect(posts).toBe(1)
})
for (const fails of [false, true]) test("Ручной запуск: поздний ответ после смены режима/" + fails, async ({ page }) => {
await setup(page); let started = false, completed = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/scheduler/jobs/backups/run-now", async r => { started = true; await gate; try { await r.fulfill(fails ? { status: 503, json: { error: "СТАРАЯ ОШИБКА ЗАПУСКА" } } : { json: ack }) } catch {} finally { completed = true } })
try {
await page.goto("/data-collection"); const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings"); await page.getByRole("button", { name: "Моковые", exact: true }).click(); await page.goto("/data-collection"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByText("Нужен live-режим", { exact: true })).toBeVisible(); await expect(page.getByText("Запуск подтверждён: Бэкапы", { exact: true })).toHaveCount(0); await expect(page.getByText(/СТАРАЯ ОШИБКА ЗАПУСКА/)).toHaveCount(0)
} finally { release() }
})
+26
View File
@@ -0,0 +1,26 @@
import { test, expect } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const row = { id: "current-run", jobKey: "backups", startedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:00:00Z", status: "ok", error: null, durationMs: 100, resultJson: null }
for (const fails of [true, false]) test("Сбор данных: поздний журнал после смены фильтра/" + fails, async ({ page }) => {
let started = false, completed = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/auth/config") return route.fulfill({ json: { required: false } })
if (path === "/api/geoip") return route.fulfill({ json: geoip })
if (path === "/api/traffic/flow/settings") return route.fulfill({ status: 503, json: { error: "NetFlow outside fixture" } })
return route.fulfill({ json: { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } })
})
await page.route("**/api/scheduler/runs?**", async route => {
if (new URL(route.request().url()).searchParams.get("jobKey") === "backups") return route.fulfill({ json: { runs: [row] } })
started = true; await gate
try { await route.fulfill(fails ? { status: 503, json: { error: "СТАРАЯ ОШИБКА ЖУРНАЛА" } } : { json: { runs: [{ ...row, id: "old-run", error: "СТАРЫЙ РЕЗУЛЬТАТ ЖУРНАЛА" }] } }) } catch {} finally { completed = true }
})
await page.goto("/data-collection"); await expect.poll(() => started).toBe(true)
await page.getByLabel("Задача", { exact: true }).selectOption("backups")
await page.getByRole("button", { name: /Бэкапы.*100 мс/ }).click(); await expect(page.getByText("current-run", { exact: true })).toBeVisible()
release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByText("current-run", { exact: true })).toBeVisible(); await expect(page.getByText(/СТАРАЯ ОШИБКА ЖУРНАЛА|СТАРЫЙ РЕЗУЛЬТАТ ЖУРНАЛА/)).toHaveCount(0)
expect(errors).toEqual([])
})
@@ -0,0 +1,35 @@
import { test, expect, type Page } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const common = { enabled: true, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
const values: Record<string, object> = {
"/api/traffic/settings": { ...common, intervalSec: 45 },
"/api/servers-api-ping/settings": { ...common, enabled: false, intervalSec: 120 },
"/api/uptime/settings": { ...common, resourcesEnabled: true, pingEnabled: false, speedEnabled: false, probeIntervalSec: 15, speedIntervalSec: 60 },
"/api/internet-path/settings": common,
"/api/certificates/renew-settings": { enabled: false, intervalSec: 21600, renewBeforeDays: 30 },
}
async function setup(page: Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ json: geoip })
if (path === "/api/scheduler/status") return r.fulfill({ json: { jobs: [] } })
if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
if (path === "/api/traffic/flow/settings") return r.fulfill({ json: { enabled: false, collectorIp: "10.255.254.1", flowListenPort: 4739, wgListenPort: 51821, prefix: "10.255.254.0/24", publicEndpoint: "", hostPublicKey: "", hasHostPrivateKey: false, hubServerId: null, retentionHours: 24, topN: 200, mapServiceMinSharePct: 5, lastDatagramAt: null, lastExporterIp: null, lastError: null, packetsReceived: 0, listenerBound: false, listenerAddress: null, peers: [] } })
if (values[path]) return r.fulfill({ json: values[path] })
return r.fulfill({ status: 503, json: { error: "outside settings fixture" } })
})
}
for (const width of [1440, 390, 320]) test("Сбор данных: адаптивность/" + width, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 }); await setup(page)
await page.goto("/data-collection"); await expect(page.getByTestId("settings-traffic")).toContainText("настройки получены")
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("45")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.getByRole("button", { name: "Обновить", exact: true }).scrollIntoViewIfNeeded()
await page.screenshot({ path: "docs/ux-ui/screens/data-collection-" + width + ".png", fullPage: true })
await page.getByRole("button", { name: "Сохранить настройки планировщика" }).scrollIntoViewIfNeeded()
await expect(page.getByRole("textbox", { name: "Хранение трафика", exact: true })).toBeVisible()
await page.screenshot({ path: "docs/ux-ui/screens/data-collection-settings-" + width + ".png", fullPage: true })
})
+144
View File
@@ -0,0 +1,144 @@
import { test, expect, type Page } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const common = { enabled: true, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
const values: Record<string, object> = {
"/api/traffic/settings": { ...common, intervalSec: 45 },
"/api/servers-api-ping/settings": { ...common, enabled: false, intervalSec: 120 },
"/api/uptime/settings": { ...common, resourcesEnabled: true, pingEnabled: false, speedEnabled: false, probeIntervalSec: 15, speedIntervalSec: 60 },
"/api/internet-path/settings": common,
"/api/certificates/renew-settings": { enabled: false, intervalSec: 21600, renewBeforeDays: 30 },
}
async function setup(page: Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ json: geoip })
if (path === "/api/scheduler/status") return r.fulfill({ json: { jobs: [] } })
if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
if (values[path]) return r.fulfill({ json: values[path] })
return r.fulfill({ status: 503, json: { error: "outside settings fixture" } })
})
}
test("Настройки: неполный DTO не включает defaults и не разрешает запись", async ({ page }) => {
await setup(page); await page.route("**/api/traffic/settings", r => r.fulfill({ json: {} }))
await page.goto("/data-collection")
await expect(page.getByTestId("settings-traffic")).toContainText("Настройки сборщика не подтверждены")
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("")
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Сохранить настройки планировщика" })).toBeDisabled()
await expect(page.getByTestId("settings-rest")).toContainText("настройки получены")
})
test("Настройки: медленный uptime не задерживает трафик и REST", async ({ page }) => {
await setup(page); let started = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/uptime/settings", async r => { started = true; await gate; try { await r.fulfill({ json: values["/api/uptime/settings"] }) } catch {} })
try {
await page.goto("/data-collection"); await expect.poll(() => started).toBe(true)
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("45")
await expect(page.getByTestId("settings-rest")).toContainText("настройки получены")
await expect(page.getByTestId("settings-uptime")).toContainText("настройки не получены")
} finally { release() }
})
test("Настройки: фоновая ошибка сохраняет данные, время и черновик", async ({ page }) => {
await setup(page); let fails = false
await page.route("**/api/traffic/settings", r => r.fulfill(fails ? { status: 503, json: { error: "traffic unavailable" } } : { json: values["/api/traffic/settings"] }))
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })
await expect(field).toHaveValue("45"); await field.fill("99")
const received = await page.getByTestId("settings-traffic").getByRole("status").textContent(); fails = true
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByTestId("settings-traffic")).toContainText("traffic unavailable")
await expect(field).toHaveValue("99"); await expect(page.getByTestId("settings-traffic").getByRole("status")).toHaveText(received!)
})
test("Настройки: успешное обновление не затирает черновик", async ({ page }) => {
await setup(page); await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })
await expect(field).toHaveValue("45"); await field.fill("99")
await page.getByRole("textbox", { name: "Хранение трафика", exact: true }).fill("21")
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await expect(field).toHaveValue("99")
await expect(page.getByRole("textbox", { name: "Хранение трафика", exact: true })).toHaveValue("21")
})
test("Настройки: сохраняется только изменённое поле и сбрасывается dirty после receipt", async ({ page }) => {
await setup(page); const writes: { path: string; body: object }[] = []
await page.route("**/api/traffic/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); const body = r.request().postDataJSON(); writes.push({ path: new URL(r.request().url()).pathname, body }); return r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...body } } }) })
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99")
const save = page.getByRole("button", { name: "Сохранить настройки планировщика" }); await save.click()
await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); await expect(save).toBeDisabled()
expect(writes).toEqual([{ path: "/api/traffic/settings", body: { intervalSec: 99 } }])
})
for (const raw of [{}, { ok: false }, { ok: true, settings: { ...values["/api/traffic/settings"], intervalSec: 100 } }]) test("Настройки: неверный receipt сохраняет dirty/" + JSON.stringify(raw), async ({ page }) => {
await setup(page); let writes = 0
await page.route("**/api/traffic/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); writes++; return r.fulfill({ json: raw }) })
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99"); await page.getByRole("button", { name: "Сохранить настройки планировщика" }).click()
await expect(page.getByText("Операция не завершена", { exact: true })).toBeVisible(); await expect(field).toHaveValue("99")
await expect(page.getByRole("button", { name: "Сохранить настройки планировщика" })).toBeEnabled(); expect(writes).toBe(1)
await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toHaveCount(0)
})
test("Настройки: дробный ввод не вызывает PUT", async ({ page }) => {
await setup(page); let writes = 0; page.on("request", r => { if (r.method() === "PUT") writes++ })
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("30.5")
await page.getByRole("button", { name: "Сохранить настройки планировщика" }).click(); await expect(page.getByText("Трафик: укажите целое значение от 5", { exact: true })).toBeVisible(); expect(writes).toBe(0)
})
test("Настройки: переключатель отправляет только флаг, сохраняя dirty интервал", async ({ page }) => {
await setup(page); const writes: object[] = []
await page.route("**/api/traffic/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); const body = r.request().postDataJSON(); writes.push(body); return r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...body } } }) })
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99")
await page.getByRole("switch", { name: "Включить: Трафик", exact: true }).click()
await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); expect(writes).toEqual([{ enabled: false }]); await expect(field).toHaveValue("99")
await expect(page.getByRole("button", { name: "Сохранить настройки планировщика" })).toBeEnabled()
})
test("Настройки: частичный сбой очищает только подтверждённую часть", async ({ page }) => {
await setup(page); const writes: string[] = []
await page.route("**/api/traffic/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); writes.push("traffic"); return r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...r.request().postDataJSON() } } }) })
await page.route("**/api/uptime/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); writes.push("uptime"); return r.fulfill({ status: 503, json: { error: "uptime write unavailable" } }) })
await page.goto("/data-collection"); const traffic = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(traffic).toHaveValue("45"); await traffic.fill("99")
await page.getByRole("textbox", { name: "Хранение uptime", exact: true }).fill("21"); const save = page.getByRole("button", { name: "Сохранить настройки планировщика" }); await save.click()
await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); await expect(page.getByText(/uptime write unavailable/)).toBeVisible(); await expect(save).toBeEnabled(); await save.click(); await expect.poll(() => writes.length).toBe(3); expect(writes).toEqual(["traffic", "uptime", "uptime"])
})
test("Настройки: lock блокирует второй запуск сохранения", async ({ page }) => {
await setup(page); let writes = 0, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/traffic/settings", async r => { if (r.request().method() !== "PUT") return r.fallback(); writes++; await gate; await r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...r.request().postDataJSON() } } }) })
try {
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99")
const save = page.getByRole("button", { name: "Сохранить настройки планировщика" }); await save.click(); await expect.poll(() => writes).toBe(1); await expect(save).toBeDisabled(); await expect(field).toBeDisabled()
release(); await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); expect(writes).toBe(1)
} finally { release() }
})
test("Настройки: поздний GET не отменяет подтверждённый переключатель", async ({ page }) => {
await setup(page); let delay = false, started = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/traffic/settings", async r => {
if (r.request().method() === "PUT") return r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...r.request().postDataJSON() } } })
if (delay) { started = true; await gate }
try { await r.fulfill({ json: values["/api/traffic/settings"] }) } catch {}
})
try {
await page.goto("/data-collection"); const toggle = page.getByRole("switch", { name: "Включить: Трафик", exact: true }); await expect(toggle).toBeChecked(); delay = true
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click(); await expect.poll(() => started).toBe(true)
await toggle.click(); await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toBeVisible(); release(); await expect(toggle).not.toBeChecked()
} finally { release() }
})
test("Настройки: API 403 сохраняет черновик", async ({ page }) => {
await setup(page); await page.route("**/api/traffic/settings", r => r.request().method() === "PUT" ? r.fulfill({ status: 403, json: { error: "Нет доступа к настройкам трафика" } }) : r.fallback())
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99"); await page.getByRole("button", { name: "Сохранить настройки планировщика" }).click()
await expect(page.getByText("Нет доступа к настройкам трафика", { exact: false })).toBeVisible(); await expect(field).toHaveValue("99")
})
test("Настройки: read-only claims отключают запись", async ({ page }) => {
const claims = { sub: "fixture", email: "fixture@example.test", name: "Viewer", apps: ["mm"], permissions: ["mm:dashboard:read", "mm:traffic:read"], exp: Math.floor(Date.now()/1000)+3600 }
const token = "header." + Buffer.from(JSON.stringify(claims)).toString("base64url") + ".fixture"
await page.addInitScript(value => localStorage.setItem("mmapp_token", value), token)
await setup(page); await page.route("**/api/auth/config", r => r.fulfill({ json: { required: true, portalUrl: "http://portal.example.test" } }))
await page.goto("/data-collection"); await expect(page.getByTestId("settings-traffic")).toContainText("настройки получены")
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toBeDisabled(); await expect(page.getByRole("switch", { name: "Включить: Трафик", exact: true })).toBeDisabled()
})
test("Настройки: смена режима прекращает оставшуюся часть batch", async ({ page }) => {
await setup(page); let started = false, completed = false, uptimeWrites = 0, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/traffic/settings", async r => { if (r.request().method() !== "PUT") return r.fallback(); started = true; await gate; try { await r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...r.request().postDataJSON() } } }) } catch {} finally { completed = true } })
await page.route("**/api/uptime/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); uptimeWrites++; return r.fulfill({ json: {} }) })
try {
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99"); await page.getByRole("textbox", { name: "Хранение uptime", exact: true }).fill("21"); await page.getByRole("button", { name: "Сохранить настройки планировщика" }).click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings"); await page.getByRole("button", { name: "Моковые", exact: true }).click(); await page.goto("/data-collection"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByText("Нужен live-режим", { exact: true })).toBeVisible(); await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toHaveCount(0); expect(uptimeWrites).toBe(0)
} finally { release() }
})
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-01T00:00:00Z", due: true, created: 2, failures: 0, pruned: 1, errors: ["S3 upload unavailable"] }
for (const representation of ["jsonb", "legacy", "invalid", "foreign"] as const) test("Сбор данных: snapshot/" + representation, async ({ page }) => {
const pageErrors: string[] = []; page.on("pageerror", error => pageErrors.push(error.message))
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/traffic/flow/settings") return r.fulfill({ status: 503, json: { error: "NetFlow is outside this snapshot fixture" } })
const settings = { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } }
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/geoip" ? geoip : path === "/api/scheduler/runs" ? { runs: [{ id: "run-jsonb", jobKey: "backups", startedAt: snapshot.sampledAt, finishedAt: snapshot.sampledAt, status: "error", error: "Завершено с ограничениями", durationMs: 100, resultJson: representation === "legacy" ? JSON.stringify(snapshot) : representation === "invalid" ? { ...snapshot, created: undefined } : representation === "foreign" ? { v: 1, job: "traffic", sampledAt: snapshot.sampledAt, servers: [] } : snapshot }] } : settings })
})
await page.goto("/data-collection")
await page.getByRole("button", { name: /Бэкапы.*100 мс/ }).click()
await expect(page.getByText("run-jsonb", { exact: true })).toBeVisible()
if (representation === "invalid" || representation === "foreign") { await expect(page.getByText("Слот расписания", { exact: true })).toHaveCount(0); await expect(page.getByText("Снимок недоступен", { exact: true })).toBeVisible() }
else { await expect(page.getByText("Слот расписания", { exact: true })).toBeVisible(); await expect(page.getByText("S3 upload unavailable", { exact: true })).toBeVisible(); await expect(page.getByText("Создано", { exact: true }).locator("..")).toContainText("2") }
expect(pageErrors).toEqual([])
})
+32
View File
@@ -0,0 +1,32 @@
import { test, expect } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const job = { jobKey: "backups", enabled: false, intervalSec: 60, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null }
async function setup(page: import("@playwright/test").Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ json: geoip })
if (path === "/api/traffic/flow/settings") return r.fulfill({ status: 503, json: { error: "NetFlow outside fixture" } })
if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
return r.fulfill({ json: { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } })
})
}
for (const malformed of [true, false]) test("Статус: initial failure не подменяется drafts/" + malformed, async ({ page }) => {
await setup(page); await page.route("**/api/scheduler/status", r => r.fulfill(malformed ? { json: {} } : { status: 503, json: { error: "status unavailable" } }))
await page.goto("/data-collection"); await expect(page.getByText("Статус планировщика не получен", { exact: true })).toBeVisible()
for (const label of ["Включено задач", "Сейчас выполняется"]) await expect(page.getByText(label, { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("—")
})
test("Статус и история появляются до медленной зависимости uptime", async ({ page }) => {
await setup(page); let started = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/uptime/settings", async r => { started = true; await gate; try { await r.fulfill({ status: 503, json: { error: "uptime unavailable" } }) } catch {} })
await page.route("**/api/scheduler/status", r => r.fulfill({ json: { jobs: [job] } }))
try { await page.goto("/data-collection"); await expect.poll(() => started).toBe(true); await expect(page.getByText("Включено задач", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("0 / 1"); await expect(page.getByText("Пока нет прогонов", { exact: true })).toBeVisible(); await expect(page.getByText(/^Статус получен:/)).toBeVisible() } finally { release() }
})
test("Статус: background failure сохраняет последний статус и время", async ({ page }) => {
await setup(page); let fails = false
await page.route("**/api/scheduler/status", r => r.fulfill(fails ? { status: 503, json: { error: "status refresh unavailable" } } : { json: { jobs: [job] } }))
await page.goto("/data-collection"); await expect(page.getByText(/^Статус получен:/)).toBeVisible(); const received = await page.getByText(/^Статус получен:/).textContent(); fails = true
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByText("Статус планировщика не обновлён", { exact: true })).toBeVisible(); await expect(page.getByText("Включено задач", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("0 / 1"); await expect(page.getByText(/^Статус получен:/)).toHaveText(received!)
})
+16
View File
@@ -0,0 +1,16 @@
import { test, expect } from "@playwright/test"
test("Evolution: malformed catalogue не обрушает навигацию", async ({ page }) => {
const errors: string[] = []; let catalogs = 0; page.on("pageerror", error => errors.push(error.message))
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/evobgp/settings") return r.fulfill({ json: { baseUrl: "https://evo.example.test", enabled: true, secretConfigured: true } })
if (path === "/api/evobgp/catalog") { catalogs++; return r.fulfill({ json: {} }) }
return r.fulfill({ status: 503, json: { error: "outside catalogue fixture" } })
})
await page.goto("/data-collection"); await expect.poll(() => catalogs).toBeGreaterThan(0)
await expect(page.getByText("Журнал прогонов", { exact: true })).toBeVisible()
await page.goto("/settings"); await page.getByRole("button", { name: "EvoBGP", exact: true }).click(); await expect(page.getByText("Каталог Evolution не подтверждён: получен некорректный ответ сервера", { exact: true })).toBeVisible()
expect(errors).toEqual([])
})
+28
View File
@@ -0,0 +1,28 @@
import {test,expect,type Page} from "@playwright/test"
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
async function setup(page:Page,settings:unknown=evoSettings,catalog:unknown=evoCatalog){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/evobgp/settings"?{json:settings}:path==="/api/evobgp/catalog"?{json:catalog}:{status:503,json:{error:"outside fixture"}})})}
const cases=[{path:"domains",mock:"youtube.com"},{path:"ip-ranges",mock:"8.8.8.0/24"},{path:"asns",mock:"AS15169"},{path:"communities",mock:"youtube-bypass"}]
for(const {path,mock} of cases)for(const state of ["unknown","disabled"] as const)test("Evolution catalog: "+path+"/"+state+" never shows mock rows or confirmed zero",async({page})=>{
await setup(page,state==="unknown"?{}:{...evoSettings,enabled:false});await page.goto("/"+path)
await expect(page.getByText(state==="unknown"?"Настройки EvoBGP неизвестны":"Интеграция EvoBGP выключена",{exact:true})).toBeVisible()
await expect(page.getByText("Данные каталога не получены",{exact:true})).toBeVisible()
await expect(page.getByText(mock,{exact:true})).toHaveCount(0)
await expect(page.getByRole("row")).toHaveCount(0)
if(path!=="communities")await expect(page.getByText("Количество: —",{exact:true})).toBeVisible()
await expect(page.getByRole("button",{name:/^Добавить/})).toHaveCount(0)
})
test("Evolution catalog: known empty differs from unread and permits refresh",async({page})=>{await setup(page,evoSettings,{...evoCatalog,domains:[]});await page.goto("/domains");await expect(page.getByText("Нет доменов",{exact:true})).toBeVisible();await expect(page.getByText("0 доменов",{exact:true})).toBeVisible();await expect(page.getByText("В подтверждённом каталоге нет записей этого типа",{exact:true})).toBeVisible();await expect(page.getByText("Данные каталога не получены",{exact:true})).toHaveCount(0)})
test("Evolution catalog: malformed initial response stays unread",async({page})=>{await setup(page,evoSettings,{});await page.goto("/domains");await expect(page.getByText("Каталог EvoBGP недоступен",{exact:true})).toBeVisible();await expect(page.getByText("Данные каталога не получены",{exact:true})).toBeVisible();await expect(page.getByText("0 доменов",{exact:true})).toHaveCount(0)})
test("Evolution catalog: refresh error preserves search, rows and freshness",async({page})=>{await setup(page);let fail=false;await page.route("**/api/evobgp/catalog",r=>r.fulfill(fail?{status:503,json:{error:"catalog down"}}:{json:evoCatalog}));await page.goto("/domains");await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible();const stamp=page.getByRole("status").filter({hasText:/^Каталог EvoBGP получен:/});const before=await stamp.textContent();await page.getByRole("textbox",{name:"Поиск по домену…"}).fill("kept");fail=true;await page.getByRole("button",{name:"Обновить каталог EvoBGP",exact:true}).click();await expect(page.getByText("Каталог не обновлён; показаны последние данные",{exact:true})).toBeVisible();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible();await expect(stamp).toHaveText(before!);await expect(page.getByRole("textbox",{name:"Поиск по домену…"})).toHaveValue("kept")})
test("Evolution catalog: disabled can reload settings to discover enabled integration",async({page})=>{let enabled=false;await setup(page);await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{...evoSettings,enabled}}));await page.goto("/domains");await expect(page.getByText("Интеграция EvoBGP выключена",{exact:true})).toBeVisible();enabled=true;await page.getByRole("button",{name:"Обновить каталог EvoBGP",exact:true}).click();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible()})
test("Evolution catalog: mock mode is explicit and does not read integration",async({page})=>{await page.addInitScript(()=>localStorage.setItem("routerlists:data-source","mock"));await setup(page);let requests=0;page.on("request",r=>{if(new URL(r.url()).pathname.startsWith("/api/evobgp/"))requests++});await page.goto("/asns");await expect(page.getByText("Демонстрационный каталог",{exact:true})).toBeVisible();await expect(page.getByText("AS15169",{exact:true})).toBeVisible();expect(requests).toBe(0)})
test("Evolution catalog: reader without admin cannot load integration or see demo data",async({page})=>{
const token="header."+Buffer.from(JSON.stringify({sub:"reader",apps:["mm"],permissions:["mm:dashboard:read","mm:filters:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture"
await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);await setup(page);await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let reads=0;page.on("request",r=>{if(new URL(r.url()).pathname.startsWith("/api/evobgp/"))reads++});await page.goto("/domains");await expect(page.getByText("Нет доступа к каталогу EvoBGP",{exact:true})).toBeVisible();await expect(page.getByRole("row")).toHaveCount(0);await expect(page.getByRole("button",{name:"Обновить каталог EvoBGP",exact:true})).toHaveCount(0);expect(reads).toBe(0)
})
test("Evolution catalog: selected community details follow refreshed snapshot",async({page})=>{
const community={id:"c",value:"64512:1",name:"Selected community",description:"",type:"custom",filterIds:[],serverCount:0,prefixCount:4,action:"permit",enabled:true};let prefixes=4;await setup(page);await page.route("**/api/evobgp/catalog",r=>r.fulfill({json:{...evoCatalog,communities:[{...community,prefixCount:prefixes}]}}));await page.goto("/communities");await page.getByRole("row").filter({hasText:"Selected community"}).click();const detail=page.getByText("Маршрутов",{exact:true}).locator("..");await expect(detail.getByText("4",{exact:true})).toBeVisible();prefixes=9;await page.getByRole("button",{name:"Обновить каталог EvoBGP",exact:true}).click();await expect(detail.getByText("9",{exact:true})).toBeVisible();await expect(detail.getByText("4",{exact:true})).toHaveCount(0)
})
for(const width of [1440,390,320])test("Evolution catalog: communities adaptive/"+width,async({page})=>{
await page.setViewportSize({width,height:1000});await setup(page,evoSettings,{});await page.goto("/communities");await expect(page.getByText("Данные каталога не получены",{exact:true})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true);await page.screenshot({path:"docs/ux-ui/screens/evolution-communities-"+width+".png",fullPage:true,animations:"disabled"})
})
+16
View File
@@ -0,0 +1,16 @@
import {test,expect,type Page} from "@playwright/test"
import {evoSettings as settings,evoCatalog as catalog} from "./fixtures/evobgp"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const url=new URL(r.request().url()),fresh=url.port==="8101";return r.fulfill(url.pathname==="/api/auth/config"?{headers,json:{required:false}}:url.pathname==="/api/evobgp/settings"?{headers,json:{...settings,baseUrl:fresh?"https://new.evo.test":settings.baseUrl}}:url.pathname==="/api/evobgp/catalog"?{headers,json:{...catalog,domains:[{...catalog.domains[0],domain:fresh?"fresh.example.test":"stale.example.test"}]}}:{headers,status:503,json:{error:"outside Evolution context"}})})}
for(const action of ["settings","catalog","save","test"] as const)for(const failed of [false,true])test("Evolution provider: старый backend/"+action+"/"+failed,async({page})=>{
await setup(page);let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
const path=action==="catalog"?"/api/evobgp/catalog":action==="test"?"/api/evobgp/test":"/api/evobgp/settings"
await page.route("**"+path,async r=>{if(new URL(r.request().url()).port!=="8000"||r.request().method()==="OPTIONS"||(action==="save"?r.request().method()!=="PUT":action==="settings"?r.request().method()!=="GET":false))return r.fallback();started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:action==="test"?{ok:true}:action==="settings"?settings:action==="catalog"?{...catalog,domains:[{...catalog.domains[0],domain:"stale.example.test"}]}:{...settings,...r.request().postDataJSON(),ok:true,savedFields:Object.keys(r.request().postDataJSON())}})}catch{}finally{completed=true}})
try{
await page.goto("/settings")
if(action==="save"||action==="test"){await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue(settings.baseUrl);if(action==="save"){await page.getByPlaceholder("http://localhost:8080",{exact:true}).fill("https://old-write.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click()}else await page.getByRole("button",{name:"Проверить ключ",exact:true}).click()}
await expect.poll(()=>started).toBe(true);if(action==="save"||action==="test")await page.getByRole("button",{name:"Общие",exact:true}).click()
const url=page.getByPlaceholder("http://localhost:8000",{exact:true});await url.fill("http://localhost:8101");await url.press("Enter");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");release();await expect.poll(()=>completed).toBe(true)
await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0);await expect(page.getByText("Соединение с EvoBGP установлено",{exact:true})).toHaveCount(0);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("fresh.example.test",{exact:true})).toBeVisible();await expect(page.getByText("stale.example.test",{exact:true})).toHaveCount(0)
}finally{release()}
})
+21
View File
@@ -0,0 +1,21 @@
import {test,expect} from "@playwright/test"
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
test("Evolution: unknown prefix counts are distinct from confirmed zero in grids and details",async({page})=>{
const asn={id:"a",asn:"AS64512",org:"Unknown prefixes",country:"—",prefixes:null,filter:"c",updated:"сейчас",enabled:true}
const community={id:"c",value:"64512:1",name:"Unknown routes",description:"",type:"custom",filterIds:[],serverCount:0,prefixCount:null,action:"permit",enabled:true}
const catalog={...evoCatalog,asns:[asn,{...asn,id:"zero",asn:"AS64513",org:"Zero prefixes",prefixes:0}],communities:[community,{...community,id:"zero",value:"64512:2",name:"Zero routes",prefixCount:0}]}
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/evobgp/settings"?{json:evoSettings}:path==="/api/evobgp/catalog"?{json:catalog}:{status:503,json:{error:"outside fixture"}})})
await page.goto("/asns")
const unknown=page.getByRole("row").filter({hasText:"AS64512"}),zero=page.getByRole("row").filter({hasText:"AS64513"})
await expect(unknown.getByRole("cell").nth(2)).toHaveText("—")
await expect(zero.getByRole("cell").nth(2)).toHaveText("0")
await page.locator('a[href="/communities"]').first().click()
const unknownCommunity=page.getByRole("row").filter({hasText:"Unknown routes"}),zeroCommunity=page.getByRole("row").filter({hasText:"Zero routes"})
await expect(unknownCommunity).toBeVisible()
await expect(unknownCommunity.getByRole("cell").filter({hasText:/^—$/})).toHaveCount(1)
await expect(zeroCommunity.getByRole("cell").filter({hasText:/^0$/})).toHaveCount(2)
await unknownCommunity.click()
await expect(page.getByText("Маршрутов",{exact:true}).locator("..").getByText("—",{exact:true})).toBeVisible()
})
+14
View File
@@ -0,0 +1,14 @@
import {test,expect,type Page} from "@playwright/test"
import {evoSettings as initial,evoCatalog} from "./fixtures/evobgp"
const section=(p:Page)=>p.getByRole("region",{name:"Настройки EvoBGP",exact:true})
const base=(p:Page)=>p.getByRole("textbox",{name:"Базовый URL API",exact:true})
const key=(p:Page)=>p.getByLabel("API-ключ EvoBGP",{exact:true})
async function setup(page:Page){let settings={...initial};await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/evobgp/settings"){if(r.request().method()==="PUT"){const patch=r.request().postDataJSON();settings={baseUrl:patch.baseUrl??settings.baseUrl,enabled:patch.enabled??settings.enabled,secretConfigured:patch.apiKey!==undefined?!!patch.apiKey:settings.secretConfigured};return r.fulfill({json:{...settings,ok:true,savedFields:Object.keys(patch)}})}return r.fulfill({json:settings})}return r.fulfill(path==="/api/evobgp/catalog"?{json:evoCatalog}:path==="/api/evobgp/test"?{json:{ok:true}}:{status:503,json:{error:"outside fixture"}})});await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(base(page)).toHaveValue(initial.baseUrl)}
test("Evolution form: selective URL patch, canonical receipt and no duplicate success",async({page})=>{await setup(page);const patches:unknown[]=[];page.on("request",r=>{if(r.method()==="PUT")patches.push(r.postDataJSON())});await expect(section(page).getByRole("button",{name:"Сохранить",exact:true})).toBeDisabled();await base(page).fill(" new.evo.test/ ");await section(page).getByRole("button",{name:"Сохранить",exact:true}).click();await expect(base(page)).toHaveValue("https://new.evo.test");await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toHaveCount(1);await expect(section(page).getByRole("button",{name:"Сохранить",exact:true})).toBeDisabled();expect(patches).toEqual([{baseUrl:"https://new.evo.test"}])})
test("Evolution form: refresh preserves dirty URL, toggle and replacement key",async({page})=>{await setup(page);await base(page).fill("https://draft.evo.test");await key(page).fill("draft-secret");await page.getByRole("switch",{name:"Использовать каталог EvoBGP"}).click();await page.getByRole("button",{name:"Обновить настройки EvoBGP"}).click();await expect(page.getByText("Обновление настроек EvoBGP…",{exact:true})).toHaveCount(0);await expect(base(page)).toHaveValue("https://draft.evo.test");await expect(key(page)).toHaveValue("draft-secret");await expect(page.getByRole("switch",{name:"Использовать каталог EvoBGP"})).not.toBeChecked();await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(key(page)).toHaveValue("")})
test("Evolution form: invalid URL never writes and test never saves",async({page})=>{await setup(page);let writes=0;page.on("request",r=>{if(r.method()==="PUT")writes++});await base(page).fill("https://evo.test/v1");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(section(page).getByText(/Укажите HTTP\(S\) URL/)).toBeVisible();expect(writes).toBe(0);await base(page).fill("https://draft.evo.test");await page.getByRole("button",{name:"Проверить ключ",exact:true}).click();await expect(page.getByText("Соединение с EvoBGP установлено",{exact:true})).toBeVisible();expect(writes).toBe(0);await expect(page.getByRole("button",{name:"Сохранить",exact:true})).toBeEnabled()})
test("Evolution form: unknown save preserves drafts without retry",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({status:503,json:{error:"save unknown"}})});await key(page).fill("replacement");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(section(page).getByText(/save unknown.*Черновик сохранён/)).toBeVisible();await expect(key(page)).toHaveValue("replacement");await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toHaveCount(0);expect(writes).toBe(1)})
test("Evolution form: key clear requires confirmation and preserves other drafts",async({page})=>{await setup(page);const patches:unknown[]=[];page.on("request",r=>{if(r.method()==="PUT")patches.push(r.postDataJSON())});await base(page).fill("https://draft.evo.test");await key(page).fill("future-key");await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();expect(patches).toEqual([]);await page.getByRole("alertdialog").getByRole("button",{name:"Удалить ключ",exact:true}).click();await expect(page.getByRole("alertdialog")).toHaveCount(0);await expect(page.getByText("Удаление сохранённого ключа подтверждено",{exact:true})).toBeVisible();await expect(base(page)).toHaveValue("https://draft.evo.test");await expect(key(page)).toHaveValue("future-key");expect(patches).toEqual([{apiKey:null}]);await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();expect(patches[1]).toEqual({baseUrl:"https://draft.evo.test",apiKey:"future-key"})})
test("Evolution form: malformed clear retains confirmation and typed key",async({page})=>{await setup(page);await page.route("**/api/evobgp/settings",r=>r.request().method()==="PUT"?r.fulfill({json:{}}):r.fallback());await key(page).fill("future-key");await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить ключ",exact:true}).click();await expect(page.getByRole("alertdialog").getByRole("alert")).toContainText("Подтверждения удаления нет");await page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true}).click();await expect(key(page)).toHaveValue("future-key");await expect(page.getByText("Ключ: сохранён",{exact:true})).toBeVisible()})
test("Evolution form: repeated save and other actions are locked during PUT",async({page})=>{await setup(page);let started=false,writes=0,release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/evobgp/settings",async r=>{if(r.request().method()!=="PUT")return r.fallback();started=true;writes++;await gate;try{await r.fulfill({json:{...initial,baseUrl:"https://draft.evo.test",ok:true,savedFields:["baseUrl"]}})}catch{}});try{await base(page).fill("https://draft.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).dblclick();await expect.poll(()=>started).toBe(true);await expect(page.getByRole("button",{name:"Сохранение…",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Проверить ключ",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true})).toBeDisabled();await expect(base(page)).toBeDisabled();expect(writes).toBe(1);release();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible()}finally{release()}})
+16
View File
@@ -0,0 +1,16 @@
import {test,expect,type Page} from "@playwright/test"
import {evoSettings as settings,evoCatalog as catalog} from "./fixtures/evobgp"
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p==="/api/auth/config"?{json:{required:false}}:p==="/api/evobgp/settings"?{json:settings}:p==="/api/evobgp/catalog"?{json:catalog}:{status:503,json:{error:"outside Evolution fixture"}})})}
async function open(page:Page){await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click()}
const base=(page:Page)=>page.getByPlaceholder("http://localhost:8080",{exact:true})
const settingsStamp=(page:Page)=>page.getByRole("status").filter({hasText:/^Настройки EvoBGP получены:/})
const catalogStamp=(page:Page)=>page.getByRole("status").filter({hasText:/^Каталог EvoBGP получен:/})
async function ready(page:Page){await expect(base(page)).toHaveValue(settings.baseUrl);await expect(catalogStamp(page)).toBeVisible();await expect(page.getByText("Обновление каталога EvoBGP…",{exact:true})).toHaveCount(0)}
test("Evolution provider: malformed settings остаются unknown",async({page})=>{await setup(page);await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{}}));await open(page);await expect(page.getByText("Настройки EvoBGP неизвестны",{exact:true})).toBeVisible();await expect(page.getByText("Ключ: —",{exact:true})).toBeVisible();await expect(settingsStamp(page)).toHaveCount(0);await expect(base(page)).toHaveValue("")})
test("Evolution provider: каталог не задерживает known настройки",async({page})=>{await setup(page);let started=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve);await page.route("**/api/evobgp/catalog",async r=>{started=true;await gate;try{await r.fulfill({json:catalog})}catch{}});try{await open(page);await expect.poll(()=>started).toBe(true);await expect(base(page)).toHaveValue(settings.baseUrl);await expect(settingsStamp(page)).toBeVisible();await expect(page.getByText("Загрузка каталога EvoBGP…",{exact:true})).toBeVisible();release();await expect(catalogStamp(page)).toBeVisible()}finally{release()}})
test("Evolution provider: ошибка settings GET сохраняет настройки, каталог и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/settings",r=>r.fulfill(failed?{status:503,json:{error:"settings unavailable"}}:{json:settings}));await open(page);await ready(page);const before=await settingsStamp(page).textContent(),catBefore=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Настройки EvoBGP не обновлены",{exact:true})).toBeVisible();await expect(settingsStamp(page)).toHaveText(before!);await expect(catalogStamp(page)).toHaveText(catBefore!);await expect(base(page)).toHaveValue(settings.baseUrl)})
test("Evolution provider: ошибка каталога сохраняет последний снимок и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog unavailable"}}:{json:catalog}));await open(page);await ready(page);const before=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveText(before!);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible()})
test("Evolution provider: отключение подтверждённой интеграции очищает каталог",async({page})=>{await setup(page);let enabled=true,posts=0;await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{...settings,enabled}}));await page.route("**/api/evobgp/catalog",r=>{posts++;return r.fulfill({json:catalog})});await open(page);await ready(page);const before=posts;enabled=false;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Интеграция: выключена",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP отключён в сохранённых настройках",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveCount(0);expect(posts).toBe(before)})
test("Evolution provider: сохранение не ждёт каталог и не отменяется его отказом",async({page})=>{await setup(page);let failed=false,writes=0;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog after save unavailable"}}:{json:catalog}));await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;failed=true;const patch=r.request().postDataJSON();return r.fulfill({json:{...settings,...patch,ok:true,savedFields:Object.keys(patch)}})});await open(page);await ready(page);await base(page).fill(settings.baseUrl+"/");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();expect(writes).toBe(1)})
test("Evolution provider: malformed PUT не подтверждает сохранение",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({json:{}})});await open(page);await ready(page);await base(page).fill("https://new.example.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.locator("#main-content").getByText(/Сохранение EvoBGP не подтверждено сервером/)).toBeVisible();await expect(base(page)).toHaveValue("https://new.example.test");expect(writes).toBe(1)})
test("Evolution provider: права settings admin проверяются до GET",async({page})=>{const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:dashboard:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);await setup(page);await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let reads=0;page.on("request",r=>{if(new URL(r.url()).pathname.startsWith("/api/evobgp/"))reads++});await open(page);await expect(page.getByText("Нет доступа к настройкам EvoBGP",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true})).toBeDisabled();expect(reads).toBe(0)})
+13
View File
@@ -0,0 +1,13 @@
import {test,expect} from "@playwright/test"
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
for(const width of [1440,390,320])test("Evolution form: responsive/"+width,async({page})=>{
await page.setViewportSize({width,height:1000})
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/evobgp/settings"?{json:evoSettings}:path==="/api/evobgp/catalog"?{json:evoCatalog}:{status:503,json:{error:"outside fixture"}})})
await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByRole("textbox",{name:"Базовый URL API",exact:true})).toHaveValue(evoSettings.baseUrl)
const section=page.getByRole("region",{name:"Настройки EvoBGP",exact:true})
await section.scrollIntoViewIfNeeded();expect(await section.evaluate(e=>e.scrollWidth<=e.clientWidth+2)).toBe(true);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/evolution-"+width+".png",fullPage:true,animations:"disabled"})
await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();await expect(page.getByRole("alertdialog")).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/evolution-clear-"+width+".png",fullPage:true,animations:"disabled"})
})
+26
View File
@@ -0,0 +1,26 @@
import { test, expect } from "@playwright/test"
test("Evolution: HTTP 200 без ok=true не подтверждает соединение", async ({ page }) => {
let confirmed = false; let tests = 0;
const errors: string[] = []; let catalogs = 0; page.on("pageerror", error => errors.push(error.message))
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/evobgp/settings") return r.fulfill({ json: { baseUrl: "https://evo.example.test", enabled: true, secretConfigured: true } })
if (path === "/api/evobgp/test") { tests++; return r.fulfill({ json: { ok: confirmed } }) }
if (path === "/api/evobgp/catalog") { catalogs++; return r.fulfill({ json: {} }) }
return r.fulfill({ status: 503, json: { error: "outside catalogue fixture" } })
})
await page.goto("/data-collection"); await expect.poll(() => catalogs).toBeGreaterThan(0)
await expect(page.getByText("Журнал прогонов", { exact: true })).toBeVisible()
await page.goto("/settings"); await page.getByRole("button", { name: "EvoBGP", exact: true }).click(); await expect(page.getByText("Каталог Evolution не подтверждён: получен некорректный ответ сервера", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Проверить ключ", exact: true }).click()
await expect(page.getByText("Соединение с EvoBGP не подтверждено сервером", { exact: true })).toBeVisible()
await expect(page.getByText("Соединение с EvoBGP установлено", { exact: true })).toHaveCount(0)
expect(tests).toBe(1)
confirmed = true
await page.getByRole("button", { name: "Проверить ключ", exact: true }).click()
await expect(page.getByText("Соединение с EvoBGP установлено", { exact: true })).toBeVisible()
expect(tests).toBe(2)
expect(errors).toEqual([])
})
+93
View File
@@ -0,0 +1,93 @@
import { test, expect, type Page } from "@playwright/test"
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
const makeRule = (port: string) => ({ id: "rule-1", community: port === "8101" ? "65001:201" : "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: port === "8101" ? "Новый источник" : "Старый источник" })
async function setup(page: Page) {
let oldReads = 0
await page.route("**/health", route => route.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url()), path = url.pathname
if (path === "/api/filters/rules" && url.port === "8000" && route.request().method() === "GET") oldReads++
return route.fulfill({ headers, json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [makeRule(url.port)] }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/settings")
await page.getByRole("link", { name: /^Фильтры/ }).click()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
return () => oldReads
}
async function changeContext(page: Page, change: string) {
await page.goBack()
await expect(page).toHaveURL(/settings/)
if (change === "backend") {
const input = page.getByPlaceholder("http://localhost:8000")
await input.fill("http://localhost:8101")
await input.press("Enter")
} else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.getByRole("link", { name: /^Фильтры/ }).click()
if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новый источник")
else await expect(page.locator('tr[data-row-id="rule-1"]')).toHaveCount(0)
}
async function flush(page: Page) {
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
}
for (const fails of [false, true]) {
test(`Filters: старое чтение при смене backend ${fails}`, async ({ page }) => {
await setup(page)
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let started = false
await page.route("**/api/filters/rules?serverId=1", async route => {
if (new URL(route.request().url()).port !== "8000") return route.fallback()
started = true
await gate
return route.fulfill(fails ? { headers, status: 503, json: { error: "Ошибка старого источника" } } : { headers, json: { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true } })
})
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => started).toBe(true)
await changeContext(page, "backend")
const response = page.waitForResponse(r => r.url().includes(":8000/api/filters/rules?serverId=1"))
release!()
await (await response).finished()
await flush(page)
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.locator("tr[data-row-id]")).toContainText("Новый источник")
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
await expect(page.getByText("Не удалось прочитать правила с роутера", { exact: true })).toHaveCount(0)
})
}
for (const change of ["backend", "mode"]) {
for (const fails of [false, true]) {
test(`Filters: старое применение при смене ${change}/${fails}`, async ({ page }) => {
const oldReads = await setup(page)
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let started = false
await page.route("**/api/filters/rules", async route => {
if (route.request().method() === "OPTIONS") return route.fulfill({ headers, json: {} })
if (route.request().method() !== "PUT") return route.fallback()
started = true
await gate
return route.fulfill(fails ? { headers, status: 403, json: { error: "Старое применение запрещено" } } : { headers, json: { ok: true, rules: [] } })
})
await page.locator("tr[data-row-id]").first().hover()
await page.getByRole("button", { name: "Удалить правило", exact: true }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Удалить", exact: true }).click()
await expect.poll(() => started).toBe(true)
const before = oldReads()
await changeContext(page, change)
const response = page.waitForResponse(r => r.url().includes(":8000/api/filters/rules") && r.request().method() === "PUT")
release!()
await (await response).finished()
await flush(page)
expect(oldReads()).toBe(before)
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
await expect(page.getByText("Не удалось применить правила на роутер", { exact: true })).toHaveCount(0)
await expect(page.getByRole("alertdialog")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
})
}
}
+65
View File
@@ -0,0 +1,65 @@
import { test, expect } from "@playwright/test"
for (const mode of ["append", "replace"] as const) {
test(`Filters: копирование ${mode} читает актуальную цель до PUT`, async ({ page }) => {
const makeRule = (id: string, community: string) => ({ id, community, gateway: "", gatewayTunnelId: "", action: "blackhole", description: id })
const source = makeRule("source", "65001:101"), cached = makeRule("cached", "65002:201"), fresh = makeRule("fresh", "65002:202")
let phase = 0, targetReads = 0, writes = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async route => {
const url = new URL(route.request().url()), path = url.pathname
if (path === "/api/filters/rules" && route.request().method() === "PUT") {
writes++
const body = route.request().postDataJSON()
expect(body.serverId).toBe("2")
expect(body.source).toBe("copy")
expect(body.rules.map((r: { community: string }) => r.community)).toEqual(mode === "append" ? [fresh.community, source.community] : [source.community])
expect(body.rules.some((r: { id: string }) => r.id === cached.id)).toBe(false)
return route.fulfill({ json: { ok: true, rules: body.rules } })
}
if (path === "/api/filters/rules" && url.searchParams.get("serverId") === "2") {
targetReads++
if (phase === 0) { await gate; return route.fulfill({ status: 503, json: { error: "Цель недоступна тест" } }) }
return route.fulfill({ json: { rulesets: [{ serverId: phase === 1 ? "3" : "2", rules: phase === 3 ? [{ ...fresh, description: null }] : [fresh] }], greTunnels: [], live: true, stale: phase === 2 } })
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [source] }, { serverId: "2", rules: [cached] }], greTunnels: [], live: true }
: path === "/api/servers" ? [1, 2].map(id => ({ id, name: `Роутер ${id}`, host: `192.0.2.${id}`, type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }))
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
await expect(page.getByRole("button", { name: "Копировать", exact: true })).toBeEnabled()
await page.getByRole("button", { name: "Копировать", exact: true }).click()
const dialog = page.getByRole("dialog")
const target = dialog.getByRole("button", { name: /Роутер 2/ })
await target.click()
const choice = dialog.getByRole("button", { name: mode === "append" ? /^Добавить/ : /^Заменить/ })
await choice.click()
const submit = dialog.getByRole("button", { name: "Копировать 1 правил", exact: true })
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => targetReads).toBe(1)
expect(writes).toBe(0)
await expect(target).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled()
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
release!()
await expect(dialog.getByRole("alert")).toContainText("Цель недоступна тест")
for (phase = 1; phase <= 3; phase++) {
await submit.click()
await expect.poll(() => targetReads).toBe(phase + 1)
await expect(dialog.getByRole("alert")).toContainText("Не удалось получить актуальные правила целевого устройства")
await expect(target).toHaveAttribute("aria-pressed", "true")
await expect(choice).toHaveAttribute("aria-pressed", "true")
expect(writes).toBe(0)
}
phase = 4
await submit.click()
await expect(dialog).toHaveCount(0)
expect(targetReads).toBe(5)
expect(writes).toBe(1)
})
}
+88
View File
@@ -0,0 +1,88 @@
import { test, expect } from "@playwright/test"
import type { FilterRule } from "../../lib/data"
for (const mode of ["append", "replace"] as const) {
test(`Filters: копирование ${mode} подтверждает API и сохраняет выбор при ошибке`, async ({ page }) => {
const makeRule = (id: string, community: string, description: string): FilterRule => ({ id, community, description, gateway: "", gatewayTunnelId: "", action: "blackhole" })
const sourceRules = [makeRule("source-1", "65001:101", "Выбранное правило"), makeRule("source-2", "65001:102", "Исключённое правило")]
let targetRules = [makeRule("target-1", "65002:201", "Правило цели")]
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
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/filters/rules" && route.request().method() === "PUT") {
attempts++
const body = route.request().postDataJSON()
expect(body.serverId).toBe("2")
expect(body.source).toBe("copy")
expect(body.rules).toHaveLength(mode === "append" ? 2 : 1)
const copied = body.rules.find((r: FilterRule) => r.community === "65001:101")
expect(copied).toMatchObject({ description: "Выбранное правило", action: "blackhole" })
expect(copied.id).not.toBe("source-1")
expect(body.rules.some((r: FilterRule) => r.community === "65001:102")).toBe(false)
if (mode === "append") expect(body.rules[0]).toEqual(targetRules[0])
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Копирование запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
targetRules = body.rules
return route.fulfill({ json: { ok: true, rules: targetRules } })
}
const requested = new URL(route.request().url()).searchParams.get("serverId")
const rulesets = [{ serverId: "1", rules: sourceRules }, { serverId: "2", rules: targetRules }]
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: requested ? rulesets.filter(r => r.serverId === requested) : rulesets, greTunnels: [], live: true }
: path === "/api/servers" ? [1, 2].map(id => ({ id, name: id === 1 ? "Исходный роутер" : "Целевой роутер", host: `192.0.2.${id}`, type: "exit-node", site: "TEST", country: "RU", asn: "", enabled: true, status: "online", latency: 1 }))
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
await page.getByRole("button", { name: "Копировать", exact: true }).click()
const dialog = page.getByRole("dialog")
const excluded = dialog.getByRole("button", { name: /65001:102/ })
const chosen = dialog.getByRole("button", { name: /65001:101/ })
await excluded.click()
const target = dialog.getByRole("button", { name: /Целевой роутер/ })
await target.click()
const copyMode = dialog.getByRole("button", { name: mode === "append" ? /^Добавить/ : /^Заменить/ })
await copyMode.click()
const submit = dialog.getByRole("button", { name: "Копировать 1 правил", exact: true })
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(dialog.getByRole("status")).toContainText("Ожидание подтверждения копирования")
for (const control of [excluded, chosen, target, copyMode, dialog.getByRole("button", { name: "Отмена", exact: true }), dialog.getByRole("button", { name: "Закрыть копирование", exact: true })]) await expect(control).toBeDisabled()
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
await expect(page.getByText("Скопировано!", { exact: true })).toHaveCount(0)
release!()
await expect(dialog.getByRole("alert")).toContainText("Копирование запрещено тест")
for (let attempt = 1; attempt <= 3; attempt++) {
await expect(target).toHaveAttribute("aria-pressed", "true")
await expect(copyMode).toHaveAttribute("aria-pressed", "true")
await expect(chosen).toHaveAttribute("aria-pressed", "true")
await expect(excluded).toHaveAttribute("aria-pressed", "false")
await expect(rows).toHaveCount(2)
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
await submit.click()
if (attempt < 3) {
await expect.poll(() => attempts).toBe(attempt + 1)
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил применение правил")
}
}
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(4)
await expect(rows).toHaveCount(2)
await page.getByRole("button", { name: "Копировать", exact: true }).click()
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(excluded).toHaveAttribute("aria-pressed", "true")
await expect(dialog.getByRole("button", { name: /Целевой роутер/ })).toHaveAttribute("aria-pressed", "false")
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
await page.getByRole("option", { name: /Целевой роутер/ }).click()
await expect(rows).toHaveCount(mode === "append" ? 2 : 1)
await expect(rows.filter({ hasText: "Выбранное правило" })).toHaveCount(1)
await expect(rows.filter({ hasText: "Исключённое правило" })).toHaveCount(0)
})
}
+69
View File
@@ -0,0 +1,69 @@
import { test, expect } from "@playwright/test"
for (const invalid of ["container", "rule", "duplicate", "server"] as const) {
test(`Filters: повреждённый ответ ${invalid}, первый сбой, повтор и сохранение данных`, async ({ page }) => {
let phase = 0
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Подтверждённые данные" }
const server = { id: 1, name: "Реальный каталог тест", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }
const good = { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
const bad = invalid === "container" ? { rulesets: {} } : invalid === "rule" ? { ...good, rulesets: [{ serverId: "1", rules: [{ ...rule, description: null }] }] } : { ...good, rulesets: [{ serverId: "1", rules: [rule, rule] }] }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/filters/rules" && phase === 2 && invalid === "server") return route.fulfill({ status: 503, json: { error: "Опрос недоступен тест" } })
return route.fulfill({ json: path === "/api/filters/rules" ? phase !== 1 && invalid !== "server" ? bad : good
: path === "/api/servers" ? [phase === 0 && invalid === "server" ? { ...server, host: null } : server]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить Filters" })).toBeVisible()
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await expect(page.getByPlaceholder("Community, gateway, описание…")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toHaveCount(0)
phase = 1
await page.getByRole("button", { name: "Повторить загрузку", exact: true }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
await expect(rows.first()).toContainText("Подтверждённые данные")
await expect(page.getByRole("option", { name: /Реальный каталог тест/ })).toHaveCount(1)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
phase = 2
const refresh = page.getByRole("button", { name: "Обновить", exact: true })
await refresh.click()
await expect(refresh).toBeEnabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeDisabled()
await expect(rows).toHaveCount(1)
await expect(rows.first()).toContainText("Подтверждённые данные")
phase = 1
await refresh.click()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
})
}
test("Filters: повреждённые подтверждённые правила не закрывают форму и не заменяют таблицу", async ({ page }) => {
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Прежние данные" }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/filters/rules" && route.request().method() === "PUT") return route.fulfill({ json: { ok: true, rules: [{ ...rule, gateway: null }] } })
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
await rows.first().hover()
await rows.first().getByRole("button", { name: "Редактировать правило", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Описание правила" }).fill("Сохранить ввод")
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил применение правил")
await expect(dialog.getByRole("textbox", { name: "Описание правила" })).toHaveValue("Сохранить ввод")
await expect(rows.first()).toContainText("Прежние данные")
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
})
+78
View File
@@ -0,0 +1,78 @@
import { test, expect } from "@playwright/test"
import type { FilterRule } from "../../lib/data"
for (const deletingId of ["rule-1", "rule-2"]) {
test(`Filters: удаление ${deletingId} сохраняет подтверждение при ошибке`, async ({ page }) => {
let rules: FilterRule[] = [1, 2].map(id => ({ id: `rule-${id}`, community: `65001:10${id}`, gateway: "", gatewayTunnelId: "", action: "blackhole", description: `Правило ${id}` }))
const deleting = rules.find(r => r.id === deletingId)!
const retained = rules.find(r => r.id !== deletingId)!
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
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/filters/rules" && route.request().method() === "PUT") {
attempts++
const body = route.request().postDataJSON()
expect(body.serverId).toBe("1")
expect(body.source).toBe("apply")
expect(body.rules).toEqual([retained])
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Удаление запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
rules = body.rules
return route.fulfill({ json: { ok: true, rules } })
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.1", type: "exit-node", site: "TEST", country: "RU", asn: "", enabled: true, status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
const deletingRow = rows.filter({ hasText: deleting.community })
await expect(rows).toHaveCount(2)
const open = async () => {
await deletingRow.hover()
await deletingRow.getByRole("button", { name: "Удалить правило", exact: true }).click()
}
await open()
const dialog = page.getByRole("alertdialog")
await expect(dialog).toContainText(deleting.community)
await expect(dialog).toContainText("Тестовый роутер")
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(0)
await open()
const remove = dialog.getByRole("button", { name: "Удалить", exact: true })
await remove.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(remove).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled()
await expect(dialog.getByRole("status")).toContainText("Ожидание подтверждения удаления")
await expect(rows).toHaveCount(2)
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
release!()
await expect(dialog.getByRole("alert")).toContainText("Удаление запрещено тест")
await expect(deletingRow).toHaveCount(1)
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
await open()
await expect(dialog.getByRole("alert")).toHaveCount(0)
for (let attempt = 2; attempt <= 3; attempt++) {
await remove.click()
await expect.poll(() => attempts).toBe(attempt)
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил применение правил")
await expect(rows).toHaveCount(2)
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
}
await remove.click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(4)
await expect(deletingRow).toHaveCount(0)
await expect(rows).toHaveCount(1)
await expect(rows.first()).toContainText(retained.community)
})
}
+47
View File
@@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test"
test("Filters: время чтения и подтверждения API сохраняется при сбое", async ({ page }) => {
let fails = false
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Правило" }
await page.clock.install({ time: new Date("2026-09-30T01:00:00Z") })
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url()), path = url.pathname
if (path === "/api/filters/rules" && route.request().method() === "PUT") return route.fulfill({ json: { ok: true, rules: route.request().postDataJSON().rules } })
if (fails && (path === "/api/filters/rules" && url.searchParams.has("serverId") || path === "/api/filters/gre-tunnels" || path === "/api/recursive-routes")) return route.fulfill({ status: 503, json: { error: "Сбой свежести тест" } })
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const group = page.getByRole("group", { name: "Свежесть данных устройства" })
const rules = group.locator('[data-source-time="rules"]'), gre = group.locator('[data-source-time="gre"]'), rec = group.locator('[data-source-time="recursive"]')
for (const item of [rules, gre, rec]) await expect(item.locator("time")).toHaveCount(1)
const before = await rules.locator("time").getAttribute("datetime")
const greBefore = await gre.locator("time").getAttribute("datetime")
const recBefore = await rec.locator("time").getAttribute("datetime")
fails = true
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeDisabled()
await expect(rules.locator("time")).toHaveAttribute("datetime", before!)
await expect(gre.locator("time")).toHaveAttribute("datetime", greBefore!)
await expect(rec.locator("time")).toHaveAttribute("datetime", recBefore!)
fails = false
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
await expect(rules.locator("time")).not.toHaveAttribute("datetime", before!)
await expect(gre.locator("time")).not.toHaveAttribute("datetime", greBefore!)
await expect(rec.locator("time")).not.toHaveAttribute("datetime", recBefore!)
const afterRead = await rules.locator("time").getAttribute("datetime")
await page.clock.fastForward(60_000)
await page.locator("tr[data-row-id]").first().hover()
await page.getByRole("button", { name: "Удалить правило", exact: true }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Удалить", exact: true }).click()
await expect(page.getByRole("alertdialog")).toHaveCount(0)
await expect(rules).toContainText("Применение подтверждено API")
await expect(rules.locator("time")).not.toHaveAttribute("datetime", afterRead!)
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
})
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test"
for (const kind of ["gre", "recursive"] as const) {
test(`Filters: каталог ${kind} сохраняется при сбое и блокирует маршрут/копирование`, async ({ page }) => {
let phase = 0, writes = 0, catalogReads = 0
const tunnel = (id: string) => ({ id: "t" + id, serverId: id, name: "GRE-" + id, localAddress: "192.0.2." + id, remoteAddress: "198.51.100.1", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" })
const rec = { id: "rec1", dstAddress: "10.0.0.2/32", gateway: "198.51.100.1", distance: 1, routingTable: "main", comment: "Рекурсивный шлюз", disabled: false }
const rule = { id: "rule-1", community: "65001:101", gateway: "10.0.0.2", gatewayTunnelId: kind === "gre" ? "t1" : "rec:rec1", action: "route", description: "Маршрут" }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url()), path = url.pathname, id = url.searchParams.get("serverId") ?? "1"
if (path === "/api/filters/rules" && route.request().method() === "PUT") { writes++; return route.fulfill({ json: { ok: true, rules: route.request().postDataJSON().rules } }) }
const catalog = kind === "gre" ? "/api/filters/gre-tunnels" : "/api/recursive-routes"
if (path === catalog) catalogReads++
if (path === catalog && phase === 1) return route.fulfill({ status: 503, json: { error: "Каталог недоступен тест" } })
if (path === catalog && phase === 2) return route.fulfill({ json: kind === "gre" ? { tunnels: [{ ...tunnel(id), name: null }] } : { routes: [rec], stale: true } })
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }, { serverId: "2", rules: [] }], live: true, greTunnels: [] }
: path === "/api/servers" ? [1, 2].map(i => ({ id: i, name: "Роутер " + i, host: "192.0.2." + i, type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }))
: path === "/api/filters/gre-tunnels" ? { tunnels: kind === "gre" && phase !== 3 ? [tunnel(id)] : [] }
: path === "/api/recursive-routes" ? { routes: kind === "recursive" && phase !== 3 ? [rec] : [], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const row = page.locator("tr[data-row-id]").first()
await expect(row).toContainText(kind === "gre" ? "GRE-1" : rec.dstAddress)
phase = 1
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Каталог недоступен тест" })).toBeVisible()
await expect(row).toContainText(kind === "gre" ? "GRE-1" : rec.dstAddress)
await row.hover()
await row.getByRole("button", { name: "Редактировать правило", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Описание правила" }).fill("Сохранить маршрут")
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog.getByRole("alert")).toContainText("Шлюзы устройства не подтверждены")
expect(writes).toBe(0)
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
await page.getByRole("button", { name: "Копировать", exact: true }).click()
await dialog.getByRole("button", { name: /Роутер 2/ }).click()
const copy = dialog.getByRole("button", { name: "Копировать 1 правил", exact: true })
await expect(copy).toBeDisabled()
phase = 2
let priorReads = catalogReads
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect.poll(() => catalogReads).toBeGreaterThanOrEqual(priorReads + 2)
await expect(copy).toBeDisabled()
await expect(dialog.getByRole("alert")).toContainText("сопоставление шлюзов не подтверждены")
phase = 3
priorReads = catalogReads
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect.poll(() => catalogReads).toBeGreaterThanOrEqual(priorReads + 2)
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())))
await expect(copy).toBeDisabled()
phase = 4
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect(copy).toBeEnabled()
await expect(dialog.getByRole("alert")).toHaveCount(0)
await copy.click()
await expect(dialog).toHaveCount(0)
expect(writes).toBe(1)
})
}
+108
View File
@@ -0,0 +1,108 @@
import { test, expect } from "@playwright/test"
for (const failure of ["http", "wrong-server", "stale"] as const) {
test(`Filters: замок восстановления и отказ чтения ${failure}`, async ({ page }) => {
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Правило" }
let restores = 0, revisionReads = 0, readsAfterRestore = 0
let releaseRead: (() => void) | undefined, releaseHistory: (() => void) | undefined
const readGate = new Promise<void>(resolve => { releaseRead = resolve })
const historyGate = new Promise<void>(resolve => { releaseHistory = resolve })
let historyWaiting = false
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.endsWith("/restore")) {
restores++
expect(route.request().postDataJSON()).toEqual({ serverId: "1" })
if (restores === 1) return route.fulfill({ status: 403, json: { error: "Откат запрещён тест" } })
return route.fulfill({ json: { ok: true } })
}
if (path === "/api/filters/revisions") {
revisionReads++
if (restores === 3) { historyWaiting = true; await historyGate }
return route.fulfill({ json: { revisions: [{ id: "rev/1", serverId: "1", section: "filters", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] } })
}
if (path === "/api/filters/rules" && restores >= 2) {
readsAfterRestore++
if (restores === 2) {
if (failure === "http") return route.fulfill({ status: 503, json: { error: "Чтение недоступно тест" } })
return route.fulfill({ json: { rulesets: [{ serverId: failure === "wrong-server" ? "2" : "1", rules: [] }], greTunnels: [], stale: failure === "stale" } })
}
await readGate
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
await page.getByRole("button", { name: "История", exact: true }).click()
const sheet = page.getByRole("dialog")
await sheet.getByRole("button", { name: "Откатить", exact: true }).click()
const confirm = page.getByRole("alertdialog")
const restore = confirm.getByRole("button", { name: "Откатить", exact: true })
await restore.click()
await expect(confirm.getByRole("alert").filter({ hasText: "Откат запрещён тест" })).toBeVisible()
await restore.click()
await expect(confirm.getByRole("alert").filter({ hasText: "Версия применена, но актуальные правила не прочитаны" })).toBeVisible()
await expect(rows).toHaveCount(1)
await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0)
await expect(restore).toBeEnabled()
await restore.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => restores).toBe(3)
await expect.poll(() => readsAfterRestore).toBe(2)
await expect(restore).toBeDisabled()
await expect(confirm.getByRole("button", { name: "Отмена" })).toBeDisabled()
for (const name of ["Обновить", "История", "Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true, includeHidden: true })).toBeDisabled()
await page.keyboard.press("Escape")
await expect(confirm).toBeVisible()
releaseRead!()
await expect.poll(() => historyWaiting).toBe(true)
await expect(restore).toBeDisabled()
await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeDisabled()
await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0)
releaseHistory!()
await expect(confirm).toHaveCount(0)
await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeEnabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeEnabled()
expect(restores).toBe(3)
expect(revisionReads).toBe(2)
})
}
test("Filters: двойное обновление удерживает общий замок и освобождает его после ошибки", async ({ page }) => {
let deviceReads = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Правило" }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async route => {
const url = new URL(route.request().url()), path = url.pathname
if (path === "/api/filters/rules" && url.searchParams.has("serverId")) {
deviceReads++
if (deviceReads === 2) { await gate; return route.fulfill({ status: 503, json: { error: "Сбой обновления тест" } }) }
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect.poll(() => deviceReads).toBe(1)
const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })
await refresh.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => deviceReads).toBe(2)
for (const name of ["Обновить", "История", "Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true, includeHidden: true })).toBeDisabled()
release!()
await expect(refresh).toBeEnabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeDisabled()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await refresh.click()
await expect.poll(() => deviceReads).toBe(3)
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeEnabled()
})
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test"
for (const kind of ["gre", "recursive"] as const) {
test(`Filters: каталог ${kind} с masked IPsec сохраняется при сбое и блокирует маршрут/копирование`, async ({ page }) => {
let phase = 0, writes = 0, catalogReads = 0
const tunnel = (id: string) => ({ id: "t" + id, serverId: id, name: "GRE-" + id, localAddress: "192.0.2." + id, remoteAddress: "198.51.100.1", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "", ipsec: { secret: "" }, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" })
const rec = { id: "rec1", dstAddress: "10.0.0.2/32", gateway: "198.51.100.1", distance: 1, routingTable: "main", comment: "Рекурсивный шлюз", disabled: false }
const rule = { id: "rule-1", community: "65001:101", gateway: "10.0.0.2", gatewayTunnelId: kind === "gre" ? "t1" : "rec:rec1", action: "route", description: "Маршрут" }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url()), path = url.pathname, id = url.searchParams.get("serverId") ?? "1"
if (path === "/api/filters/rules" && route.request().method() === "PUT") { writes++; return route.fulfill({ json: { ok: true, rules: route.request().postDataJSON().rules } }) }
const catalog = kind === "gre" ? "/api/filters/gre-tunnels" : "/api/recursive-routes"
if (path === catalog) catalogReads++
if (path === catalog && phase === 1) return route.fulfill({ status: 503, json: { error: "Каталог недоступен тест" } })
if (path === catalog && phase === 2) return route.fulfill({ json: kind === "gre" ? { tunnels: [{ ...tunnel(id), name: null }] } : { routes: [rec], stale: true } })
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }, { serverId: "2", rules: [] }], live: true, greTunnels: [] }
: path === "/api/servers" ? [1, 2].map(i => ({ id: i, name: "Роутер " + i, host: "192.0.2." + i, type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }))
: path === "/api/filters/gre-tunnels" ? { tunnels: kind === "gre" && phase !== 3 ? [tunnel(id)] : [] }
: path === "/api/recursive-routes" ? { routes: kind === "recursive" && phase !== 3 ? [rec] : [], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const row = page.locator("tr[data-row-id]").first()
await expect(row).toContainText(kind === "gre" ? "GRE-1" : rec.dstAddress)
phase = 1
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Каталог недоступен тест" })).toBeVisible()
await expect(row).toContainText(kind === "gre" ? "GRE-1" : rec.dstAddress)
await row.hover()
await row.getByRole("button", { name: "Редактировать правило", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Описание правила" }).fill("Сохранить маршрут")
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog.getByRole("alert")).toContainText("Шлюзы устройства не подтверждены")
expect(writes).toBe(0)
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
await page.getByRole("button", { name: "Копировать", exact: true }).click()
await dialog.getByRole("button", { name: /Роутер 2/ }).click()
const copy = dialog.getByRole("button", { name: "Копировать 1 правил", exact: true })
await expect(copy).toBeDisabled()
phase = 2
let priorReads = catalogReads
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect.poll(() => catalogReads).toBeGreaterThanOrEqual(priorReads + 2)
await expect(copy).toBeDisabled()
await expect(dialog.getByRole("alert")).toContainText("сопоставление шлюзов не подтверждены")
phase = 3
priorReads = catalogReads
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect.poll(() => catalogReads).toBeGreaterThanOrEqual(priorReads + 2)
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())))
await expect(copy).toBeDisabled()
phase = 4
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect(copy).toBeEnabled()
await expect(dialog.getByRole("alert")).toHaveCount(0)
await copy.click()
await expect(dialog).toHaveCount(0)
expect(writes).toBe(1)
})
}
+81
View File
@@ -0,0 +1,81 @@
import { test, expect } from "@playwright/test"
import { mkdirSync } from "node:fs"
import { evoSettings, evoCatalog } from "./fixtures/evobgp"
import { parseEvoBgpCatalog } from "../../lib/evobgp-catalog"
const catalog = parseEvoBgpCatalog({ ...evoCatalog, communities: [{
id: "blackhole", value: "65535:666", name: "RFC Blackhole", description: "RTBH",
type: "standard", filterIds: [], serverCount: 2, prefixCount: null, action: "deny", enabled: true,
}] })
for (const device of ["desktop", "mobile"] as const) {
test(`Filters: клавиатурный ввод и layout ${device}`, async ({ page }) => {
await page.setViewportSize(device === "desktop" ? { width: 1440, height: 1000 } : { width: 390, height: 844 })
await page.clock.install({ time: new Date("2026-09-30T01:00:00Z") })
const rulesFor = (id: string) => [
{ id: id + "-youtube", community: "65001:100", gateway: "10.0.0.2", gatewayTunnelId: "t" + id, action: "route", description: "Видеосервисы через основной шлюз" },
{ id: id + "-streaming", community: "65001:200", gateway: "10.0.0.2", gatewayTunnelId: "t" + id, action: "route", description: "Потоковые сервисы" },
{ id: id + "-blackhole", community: "65535:666", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "RTBH — защита от DDoS" },
]
const rulesets = [{ serverId: "1", rules: rulesFor("1") }, { serverId: "2", rules: rulesFor("2") }]
const tunnel = (id: string) => ({ id: "t" + id, serverId: id, name: "gre-core-edge", localAddress: "192.0.2." + id, remoteAddress: "198.51.100.1", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" })
let writes = 0
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url()), path = url.pathname, id = url.searchParams.get("serverId")
if (path === "/api/filters/rules" && route.request().method() === "PUT") {
writes++
const body = route.request().postDataJSON()
expect(body.serverId).toBe(device === "mobile" ? "2" : "1")
expect(body.rules.at(-1)).toMatchObject({ community: "65001:777", action: "blackhole", description: "Новый фильтр", gateway: "", gatewayTunnelId: "" })
rulesets.find(rs => rs.serverId === body.serverId)!.rules = body.rules
return route.fulfill({ json: { ok: true, rules: body.rules } })
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: id ? rulesets.filter(rs => rs.serverId === id) : rulesets, greTunnels: [], live: true }
: path === "/api/servers" ? [1, 2].map(i => ({ id: i, name: i === 1 ? "Основной роутер" : "Резервный роутер", host: "192.0.2." + i, type: "exit-node", enabled: true, country: "RU", site: "CORE", asn: "65001", status: "online", latency: 3 }))
: path === "/api/filters/gre-tunnels" ? { tunnels: [tunnel(id ?? "1")] }
: path === "/api/evobgp/settings" ? evoSettings
: path === "/api/evobgp/catalog" ? catalog
: path === "/api/recursive-routes" ? { routes: [], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
await expect(page.locator("tr[data-row-id]")).toHaveCount(3)
await expect(page.getByRole("group", { name: "Свежесть данных устройства" }).locator("time")).toHaveCount(3)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
mkdirSync("docs/ux-ui/screens", { recursive: true })
await page.screenshot({ path: `docs/ux-ui/screens/filters-${device}.png` })
if (device === "mobile") {
await page.getByRole("button", { name: "Серверы", exact: true }).click()
const rail = page.getByRole("dialog")
await rail.getByRole("option", { name: /Резервный роутер/ }).click()
await expect(rail).toHaveCount(0)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
}
const create = page.getByRole("button", { name: "Новое правило", exact: true })
await create.focus()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await dialog.getByRole("button", { name: "Добавить", exact: true }).click()
await expect(dialog.getByRole("alert")).toContainText("BGP Community обязательно")
const community = dialog.getByRole("combobox", { name: /BGP Community/ })
await community.fill("65535")
await expect(page.getByRole("option", { name: /65535:666/ })).toBeVisible()
await page.keyboard.press("ArrowDown")
await page.keyboard.press("Enter")
await expect(community).toHaveValue("65535:666")
await expect(dialog.getByRole("button", { name: /⊘ Blackhole/ })).toHaveAttribute("aria-pressed", "true")
await community.fill("65001:777")
await page.keyboard.press("Tab")
await dialog.getByRole("textbox", { name: "Описание правила" }).fill("Новый фильтр")
expect(await dialog.evaluate(el => el.getBoundingClientRect().right <= window.innerWidth && el.getBoundingClientRect().left >= 0)).toBe(true)
await page.screenshot({ path: `docs/ux-ui/screens/filters-rule-${device}.png` })
await dialog.getByRole("button", { name: "Добавить", exact: true }).focus()
await page.keyboard.press("Enter")
await expect(dialog).toHaveCount(0)
await expect(page.locator("tr[data-row-id]")).toHaveCount(4)
expect(writes).toBe(1)
})
}
+73
View File
@@ -0,0 +1,73 @@
import { test, expect } from "@playwright/test"
import type { FilterRule } from "../../lib/data"
for (const direction of ["up", "down"] as const) {
test(`Filters: перемещение ${direction} в поиске сохраняет порядок при ошибке`, async ({ page }) => {
let rules: FilterRule[] = [1, 2, 3].map(id => ({ id: `rule-${id}`, community: `65001:10${id}`, gateway: "", gatewayTunnelId: "", action: "blackhole", description: `Правило ${id}` }))
const original = rules.map(r => r.id)
const confirmed = direction === "up" ? ["rule-2", "rule-1", "rule-3"] : ["rule-1", "rule-3", "rule-2"]
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
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/filters/rules" && route.request().method() === "PUT") {
attempts++
const body = route.request().postDataJSON()
expect(body.serverId).toBe("1")
expect(body.source).toBe("apply")
expect(body.rules.map((r: FilterRule) => r.id)).toEqual(confirmed)
expect(rules.map(r => r.id)).toEqual(original)
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Перемещение запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
rules = body.rules
return route.fulfill({ json: { ok: true, rules } })
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.1", type: "exit-node", site: "TEST", country: "RU", asn: "", enabled: true, status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(3)
await expect(page.getByRole("button", { name: "Поднять правило 65001:101", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Опустить правило 65001:103", exact: true })).toBeDisabled()
const search = page.getByPlaceholder("Community, gateway, описание…")
await search.fill("Правило 2")
await expect(rows).toHaveCount(1)
await expect(rows.first().getByRole("cell").first()).toHaveText("2")
const up = page.getByRole("button", { name: "Поднять правило 65001:102", exact: true })
const down = page.getByRole("button", { name: "Опустить правило 65001:102", exact: true })
await expect(up).toBeEnabled()
await expect(down).toBeEnabled()
const move = direction === "up" ? up : down
await move.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(page.getByRole("status")).toContainText("Ожидание подтверждения порядка")
for (const control of [up, down, page.getByRole("button", { name: "Редактировать правило", exact: true }), page.getByRole("button", { name: "Удалить правило", exact: true }), page.getByRole("button", { name: "Новое правило", exact: true }), page.getByRole("button", { name: "Копировать", exact: true }), page.getByRole("button", { name: "Обновить", exact: true }), page.getByRole("button", { name: "История", exact: true })]) await expect(control).toBeDisabled()
release!()
await expect(page.getByRole("alert").filter({ hasText: "Перемещение запрещено тест" })).toBeVisible()
await expect(rows.first().getByRole("cell").first()).toHaveText("2")
for (let attempt = 2; attempt <= 3; attempt++) {
await rows.first().hover()
await move.click()
await expect.poll(() => attempts).toBe(attempt)
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил применение правил" })).toBeVisible()
await expect(rows.first().getByRole("cell").first()).toHaveText("2")
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
}
await move.focus()
await page.keyboard.press("Enter")
await expect.poll(() => attempts).toBe(4)
await expect(move).toBeDisabled()
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил применение правил" })).toHaveCount(0)
await search.fill("")
await expect(rows).toHaveCount(3)
expect(await rows.evaluateAll(elements => elements.map(e => e.getAttribute("data-row-id")))).toEqual(confirmed)
await expect(page.getByRole("button", { name: `${direction === "up" ? "Поднять" : "Опустить"} правило 65001:102`, exact: true })).toBeDisabled()
})
}
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test"
for (const outcome of ["success", "error"] as const) {
test(`Filters: исходный кэш readonly и поздний ${outcome} предыдущего устройства отклонён`, async ({ page }) => {
const makeRule = (id: string, community: string) => ({ id, community, gateway: "", gatewayTunnelId: "", action: "blackhole", description: id })
const firstRules = [makeRule("cache-1", "65001:101")]
let secondRules = [makeRule("device-2", "65002:201")]
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let firstWaiting = false, firstFinished = false, writes = 0
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async route => {
const url = new URL(route.request().url()), path = url.pathname
if (path === "/api/filters/rules" && route.request().method() === "PUT") {
writes++
const body = route.request().postDataJSON()
expect(body.serverId).toBe("2")
expect(body.rules).toEqual([])
secondRules = body.rules
return route.fulfill({ json: { ok: true, rules: [] } })
}
if (path === "/api/filters/rules" && url.searchParams.get("serverId") === "1") {
firstWaiting = true
await gate
if (outcome === "error") await route.fulfill({ status: 503, json: { error: "Устаревший сбой первого устройства" } })
else await route.fulfill({ json: { rulesets: [{ serverId: "1", rules: firstRules }], greTunnels: [], live: true } })
firstFinished = true
return
}
return route.fulfill({ json: path === "/api/filters/rules" ? url.searchParams.has("serverId") ? { rulesets: [{ serverId: "2", rules: secondRules }], greTunnels: [], live: true }
: { rulesets: [{ serverId: "1", rules: firstRules }, { serverId: "2", rules: secondRules }], greTunnels: [], live: false, stale: false }
: path === "/api/servers" ? [1, 2].map(id => ({ id, name: `Роутер ${id}`, host: `192.0.2.${id}`, type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }))
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
await expect.poll(() => firstWaiting).toBe(true)
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.getByRole("status")).toContainText("Чтение актуальных правил устройства")
for (const name of ["Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true })).toBeDisabled()
expect(writes).toBe(0)
await page.getByRole("option", { name: /Роутер 2/ }).click()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
const rows = page.locator("tr[data-row-id]")
await expect(rows.first()).toContainText("65002:201")
await rows.first().hover()
await rows.first().getByRole("button", { name: "Удалить правило", exact: true }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Удалить", exact: true }).click()
await expect(page.getByRole("alertdialog")).toHaveCount(0)
await expect(rows).toHaveCount(0)
expect(writes).toBe(1)
release!()
await expect.poll(() => firstFinished).toBe(true)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
await expect(page.getByRole("option", { name: /Роутер 2/ })).toHaveAttribute("aria-selected", "true")
await expect(rows).toHaveCount(0)
await expect(page.getByText("Не удалось прочитать правила с роутера", { exact: true })).toHaveCount(0)
await expect(page.getByRole("status").filter({ hasText: "Чтение актуальных правил устройства" })).toHaveCount(0)
})
}
@@ -0,0 +1,58 @@
import { test, expect } from "@playwright/test"
test("Filters: обновлённый ID рекурсивного маршрута не скрывает шлюз и выбор в форме", async ({ page }) => {
const rule = { id: "rule-1", community: "65001:120", gateway: "10.9.9.2", gatewayTunnelId: "rec:42", action: "route", description: "Маршрут" }
let writes = 0
let savedGatewayRef: string | undefined
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (r.request().method() !== "GET") {
writes++
const payload = r.request().postDataJSON()
savedGatewayRef = payload.rules[0].gatewayTunnelId
return r.fulfill({ json: { ok: true, rules: payload.rules } })
}
return r.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "jump-host", enabled: true, country: "RU", status: "online", latency: 1 }]
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [{ id: "99", dstAddress: "10.9.9.2/32", gateway: "192.0.2.2%gre-test", distance: 1, routingTable: "main", comment: "Рекурсивный тест", disabled: false }], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const row = page.locator("tr[data-row-id]").first()
await expect(row).toContainText("10.9.9.2/32")
await expect(row).not.toContainText("нет строки в списке")
await row.getByRole("button", { name: "Редактировать правило", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("tab", { name: /Рекурсив/ })).toHaveAttribute("aria-selected", "true")
await expect(dialog.getByRole("button", { name: /10\.9\.9\.2\/32/ })).toHaveClass(/border-primary/)
await expect(dialog.getByText("Выберите рекурсивный маршрут", { exact: true })).toHaveCount(0)
expect(writes).toBe(0)
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(writes).toBe(1)
expect(savedGatewayRef).toBe("rec:99")
})
test("Filters: отсутствующий маршрут и именованный GRE не подменяются по совпавшему IP", async ({ page }) => {
const rules = [
{ id: "orphan", community: "65001:120", gateway: "10.9.9.3", gatewayTunnelId: "rec:42", action: "route", description: "Нет маршрута" },
{ id: "gre", community: "65001:130", gateway: "10.9.9.2", gatewayTunnelId: "gre-test", action: "route", description: "Это GRE" },
]
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules }], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "jump-host", enabled: true, country: "RU", status: "online", latency: 1 }]
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [{ id: "99", dstAddress: "10.9.9.2/32", gateway: "192.0.2.2", distance: 1, routingTable: "main", comment: "", disabled: false }], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
await expect(rows.nth(0)).toContainText("нет строки в списке")
await expect(rows.nth(1)).not.toContainText("recursive")
})
+83
View File
@@ -0,0 +1,83 @@
import { test, expect } from "@playwright/test"
import type { FilterRule } from "../../lib/data"
for (const editing of [false, true]) {
test(`Filters: ${editing ? "редактирование" : "создание"} ждёт подтверждения и сохраняет ввод`, async ({ page }) => {
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let rules: FilterRule[] = [{ id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Исходное правило" }]
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/filters/rules" && route.request().method() === "PUT") {
attempts++
const body = route.request().postDataJSON()
expect(body.serverId).toBe("1")
expect(body.source).toBe("apply")
expect(body.rules).toHaveLength(editing ? 1 : 2)
expect(body.rules.find((r: FilterRule) => r.description === "Сохранить ввод")).toMatchObject({ community: editing ? "65001:101" : "65001:999", action: "blackhole", gateway: "", gatewayTunnelId: "" })
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Сохранение запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
rules = body.rules.map((r: FilterRule) => ({ ...r, description: r.description === "Сохранить ввод" ? "Подтверждено API" : r.description }))
return route.fulfill({ json: { ok: true, rules } })
}
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.1", type: "exit-node", site: "TEST", country: "RU", asn: "", enabled: true, status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
const open = async () => {
if (editing) {
await rows.first().hover()
await rows.first().getByRole("button", { name: "Редактировать правило", exact: true }).click()
} else await page.getByRole("button", { name: "Новое правило", exact: true }).click()
}
await open()
const dialog = page.getByRole("dialog")
if (!editing) {
await dialog.getByPlaceholder("65001:100").fill("65001:999")
await page.keyboard.press("Tab")
await dialog.getByRole("button", { name: /⊘ Blackhole/ }).click()
}
const description = dialog.getByRole("textbox", { name: "Описание правила" })
await description.fill("Сохранить ввод")
const submit = dialog.getByRole("button", { name: editing ? "Сохранить" : "Добавить", exact: true })
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(dialog.getByRole("status")).toContainText("Ожидание подтверждения сервера")
await expect(description).toBeDisabled()
await expect(dialog.getByPlaceholder("65001:100")).toBeDisabled()
await expect(dialog.getByRole("button", { name: /⊘ Blackhole/ })).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Закрыть форму", exact: true })).toBeDisabled()
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
await expect(rows).toHaveCount(1)
await expect(rows.first()).toContainText("Исходное правило")
release!()
await expect(dialog.getByRole("alert")).toContainText("Сохранение запрещено тест")
await expect(description).toHaveValue("Сохранить ввод")
for (let i = 2; i <= 3; i++) {
await submit.click()
await expect.poll(() => attempts).toBe(i)
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил применение правил")
await expect(description).toHaveValue("Сохранить ввод")
await expect(rows).toHaveCount(1)
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
}
await submit.click()
await expect(dialog).toHaveCount(0)
await expect(rows).toHaveCount(editing ? 1 : 2)
await expect(page.getByText("Подтверждено API", { exact: true })).toBeVisible()
expect(attempts).toBe(4)
await open()
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(description).toHaveValue(editing ? "Подтверждено API" : "")
})
}
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test"
for (const active of [true, false]) {
test(`Firewall: переключение адреса ${active} требует подтверждения`, async ({ page }) => {
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let currentEnabled = active
const entry = { id: "address-1", rosId: "*A/1", serverId: "1", serverName: "Роутер", family: "ip", list: "trusted", address: "192.0.2.10", comment: "Адрес тест", disabled: !active }
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 (route.request().method() === "PATCH") {
attempts++
expect(path).toBe("/api/firewall/address-lists")
expect(route.request().postDataJSON()).toMatchObject({ serverId: "1", rosId: "*A/1", family: "ip", disabled: active })
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Изменение запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
currentEnabled = !active
return route.fulfill({ json: { ok: true } })
}
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
: path === "/api/firewall/all" ? { rules: [], addressLists: [{ ...entry, disabled: !currentEnabled }] }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1")
await page.getByRole("button", { name: /Адр. листы/ }).click()
const toggle = page.getByRole("switch")
await expect(toggle).toHaveCount(1)
await toggle.evaluate(element => { (element as HTMLElement).click(); (element as HTMLElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(toggle).toBeDisabled()
await expect(page.getByRole("status").filter({ hasText: "Ожидание изменения адреса" })).toBeVisible()
release!()
await expect(page.getByRole("alert").filter({ hasText: "Изменение запрещено тест" })).toBeVisible()
for (let i = 2; i <= 3; i++) {
await toggle.click()
await expect.poll(() => attempts).toBe(i)
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил изменение адреса" })).toBeVisible()
await expect(toggle).toHaveAttribute("aria-checked", String(active))
}
await toggle.click()
await expect(toggle).toHaveAttribute("aria-checked", String(!active))
expect(attempts).toBe(4)
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил" })).toHaveCount(0)
})
}
+91
View File
@@ -0,0 +1,91 @@
import { test, expect, type Page } from "@playwright/test"
const rule = { id: "rule-1", rosId: "*1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Контекст правило", enabled: true, hits: 1 }
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
async function setup(page: Page) {
let oldReads = 0
await page.route("**/health", route => route.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url())
if (url.pathname === "/api/firewall/all" && url.port === "8000") oldReads++
return route.fulfill({ headers, json: url.pathname === "/api/auth/config" ? { required: false }
: url.pathname === "/api/firewall/all" ? { rules: [{ ...rule, port: url.port === "8101" ? "8443" : "443" }], addressLists: [] }
: url.pathname === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/settings")
await page.getByRole("link", { name: "Firewall", exact: true }).click()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
return () => oldReads
}
async function changeBackend(page: Page) {
const field = page.getByPlaceholder("http://localhost:8000")
await field.fill("http://localhost:8101")
await field.press("Enter")
}
async function flush(page: Page) {
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
}
for (const fails of [false, true]) {
test(`Firewall: смена backend игнорирует старое чтение ${fails}`, async ({ page }) => {
await setup(page)
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let started = false
await page.route("**/api/firewall/all", async route => {
if (new URL(route.request().url()).port !== "8000") return route.fallback()
started = true
await gate
return route.fulfill(fails ? { headers, status: 500, json: { error: "Ошибка старого источника тест" } } : { headers, json: { rules: [], addressLists: [] } })
})
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => started).toBe(true)
await page.goBack()
await expect(page).toHaveURL(/settings/)
await changeBackend(page)
await page.getByRole("link", { name: "Firewall", exact: true }).click()
await expect(page.locator("tr[data-row-id]")).toContainText("8443")
const response = page.waitForResponse(r => r.url().includes(":8000/api/firewall/all"))
release!()
await (await response).finished()
await flush(page)
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.locator("tr[data-row-id]")).toContainText("8443")
await expect(page.getByRole("alert").filter({ hasText: "Ошибка старого источника" })).toHaveCount(0)
})
}
for (const change of ["backend", "mode"]) {
test(`Firewall: позднее удаление не меняет новый контекст ${change}`, async ({ page }) => {
const oldReads = await setup(page)
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let attempts = 0
await page.route("**/api/firewall/rules", async route => {
if (route.request().method() === "OPTIONS") return route.fulfill({ headers, json: {} })
attempts++
await gate
return route.fulfill({ headers, json: { ok: true } })
})
await page.getByRole("button", { name: "Действия: forward accept", exact: true }).click()
await page.getByRole("menuitem", { name: "Удалить правило", exact: true }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Удалить", exact: true }).click()
await expect.poll(() => attempts).toBe(1)
const readsBefore = oldReads()
await page.goBack()
await expect(page).toHaveURL(/settings/)
if (change === "backend") await changeBackend(page)
else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.getByRole("link", { name: "Firewall", exact: true }).click()
if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("8443")
else await expect(page.locator('tr[data-row-id="rule-1"]')).toHaveCount(0)
const response = page.waitForResponse(r => r.url().includes(":8000/api/firewall/rules") && r.request().method() === "DELETE")
release!()
await (await response).finished()
await flush(page)
expect(oldReads()).toBe(readsBefore)
await expect(page.getByText("Правило удалено", { exact: true })).toHaveCount(0)
await expect(page.getByRole("alertdialog")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
})
}
+87
View File
@@ -0,0 +1,87 @@
import { test, expect, type Page } from "@playwright/test"
const rule = { id: "rule-1", rosId: "*R/1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Правило тест", enabled: true, hits: 1 }
const server = { id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }
async function setup(page: Page) {
const control: { payload: unknown; servers: unknown; fail: boolean } = { payload: { rules: [rule], addressLists: [] }, servers: [server], fail: false }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/firewall/all" && control.fail) return route.fulfill({ status: 503, json: { error: "Загрузка недоступна тест" } })
return route.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/firewall/all" ? control.payload : path === "/api/servers" ? control.servers : {} })
})
return control
}
for (const invalid of ["container", "rule", "duplicate", "address", "servers"]) {
test(`Firewall: повреждённый ответ ${invalid} сохраняет последний список`, async ({ page }) => {
const control = await setup(page)
await page.goto("/firewall?server=1")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
if (invalid === "servers") control.servers = [{ ...server, enabled: "yes" }]
else control.payload = invalid === "container" ? { rules: {} } : invalid === "rule" ? { rules: [{ ...rule, enabled: "yes" }], addressLists: [] }
: invalid === "duplicate" ? { rules: [rule, rule], addressLists: [] } : { rules: [rule], addressLists: [{ id: "address-1", address: null }] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Сервер вернул некорректные данные Firewall" })).toBeVisible()
await expect(rows).toHaveCount(1)
await expect(page.getByText("Показаны последние успешно загруженные данные.", { exact: false })).toBeVisible()
control.payload = { rules: [], addressLists: [] }
control.servers = [server]
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(rows).toHaveCount(0)
await expect(page.getByRole("alert").filter({ hasText: "Сервер вернул" })).toHaveCount(0)
await expect(page.getByLabel("Сводка Firewall")).not.toContainText("—")
})
}
test("Firewall: первый сбой показывает неизвестные KPI, повтор загружает данные", async ({ page }) => {
const control = await setup(page)
control.fail = true
await page.goto("/firewall?server=1")
await expect(page.getByRole("alert").filter({ hasText: "Загрузка недоступна тест" })).toBeVisible()
await expect(page.getByLabel("Сводка Firewall")).toContainText("—")
await expect(page.getByRole("status").filter({ hasText: "Данные Firewall пока недоступны" })).toBeVisible()
control.fail = false
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
control.fail = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Загрузка недоступна тест" })).toBeVisible()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
})
for (const fails of [false, true]) {
test(`Firewall: позднее чтение до мутации игнорируется ${fails}`, async ({ page }) => {
const control = await setup(page)
await page.goto("/firewall?server=1")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let reads = 0
await page.route("**/api/firewall/all", async route => {
reads++
if (reads !== 1) return route.fallback()
await gate
return route.fulfill(fails ? { status: 500, json: { error: "Поздняя ошибка тест" } } : { json: { rules: [], addressLists: [] } })
})
await page.route("**/api/firewall/rules", route => {
expect(route.request().method()).toBe("PATCH")
control.payload = { rules: [{ ...rule, enabled: false }], addressLists: [] }
return route.fulfill({ json: { ok: true } })
})
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => reads).toBe(1)
await rows.getByRole("switch").click()
await expect(rows.getByRole("switch")).toHaveAttribute("aria-checked", "false")
await expect(rows.getByRole("switch")).toBeEnabled()
const response = page.waitForResponse(r => r.url().endsWith("/api/firewall/all"))
release!()
await (await response).finished()
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
await expect(rows).toHaveCount(1)
await expect(rows.getByRole("switch")).toHaveAttribute("aria-checked", "false")
await expect(page.getByRole("alert").filter({ hasText: "Поздняя ошибка тест" })).toHaveCount(0)
})
}
+72
View File
@@ -0,0 +1,72 @@
import { test, expect } from "@playwright/test"
for (const kind of ["rule", "address"] as const) {
for (const family of ["ip", "ip6"] as const) {
test(`Firewall: удаление ${kind}/${family} требует подтверждения`, async ({ page }) => {
let attempts = 0
let deleted = false
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
const rule = { id: "rule-1", rosId: "*R/1", serverId: "1", serverName: "Роутер", family, table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Правило тест", enabled: true, hits: 1 }
const entry = { id: "address-1", rosId: "*A/1", serverId: "1", serverName: "Роутер", family, list: "trusted", address: family === "ip" ? "192.0.2.10" : "2001:db8::10", comment: "Адрес тест", disabled: false }
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 (route.request().method() === "DELETE") {
attempts++
expect(path).toBe(kind === "rule" ? "/api/firewall/rules" : "/api/firewall/address-lists")
expect(route.request().postDataJSON()).toEqual(kind === "rule"
? { serverId: "1", family, table: "filter", rosId: "*R/1" }
: { serverId: "1", family, rosId: "*A/1" })
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Удаление запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
deleted = true
return route.fulfill({ json: { ok: true } })
}
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
: path === "/api/firewall/all" ? { rules: kind === "rule" && !deleted ? [rule] : [], addressLists: kind === "address" && !deleted ? [entry] : [] }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1")
if (family === "ip6") await page.getByRole("button", { name: "IPv6", exact: true }).click()
if (kind === "address") await page.getByRole("button", { name: /Адр. листы/ }).click()
const open = async () => {
await page.getByRole("button", { name: kind === "rule" ? "Действия: forward accept" : "Действия: " + entry.address, exact: true }).click()
await page.getByRole("menuitem", { name: kind === "rule" ? "Удалить правило" : "Удалить", exact: true }).click()
}
await open()
const dialog = page.getByRole("alertdialog")
await expect(dialog).toContainText("Роутер")
await expect(dialog).toContainText(kind === "rule" ? rule.comment : entry.address)
await dialog.getByRole("button", { name: "Отмена" }).click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(0)
await open()
const remove = dialog.getByRole("button", { name: "Удалить", exact: true })
await remove.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(remove).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Отмена" })).toBeDisabled()
await expect(dialog.getByRole("status")).toContainText("Ожидание результата")
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
release!()
await expect(dialog.getByRole("alert")).toContainText("Удаление запрещено тест")
await dialog.getByRole("button", { name: "Отмена" }).click()
await expect(dialog).toHaveCount(0)
await open()
await expect(dialog.getByRole("alert")).toHaveCount(0)
for (let i = 2; i <= 3; i++) {
await remove.click()
await expect.poll(() => attempts).toBe(i)
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил удаление")
await expect(page.getByText(kind === "rule" ? "Правило удалено" : "Запись удалена", { exact: true })).toHaveCount(0)
}
await remove.click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(4)
await expect(page.getByRole("button", { name: kind === "rule" ? "Действия: forward accept" : "Действия: " + entry.address, exact: true })).toHaveCount(0)
})
}
}
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test"
for (const kind of ["rule", "address"] as const) {
test(`Firewall: новый draft ${kind} при повторном открытии и возврат фокуса`, async ({ page }) => {
const rule = { id: "r1", rosId: "*1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Исходный комментарий", enabled: true, hits: 1 }
const entry = { id: "a1", rosId: "*A1", serverId: "1", serverName: "Роутер", family: "ip", list: "trusted", address: "192.0.2.10", comment: "Исходный комментарий", disabled: false }
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const p = new URL(r.request().url()).pathname
return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/firewall/all" ? { rules: [rule], addressLists: [entry] } : p === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1"); await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
if (kind === "address") await page.getByRole("button", { name: /Адр. листы/ }).click()
const action = page.getByRole("button", { name: kind === "rule" ? "Действия: forward accept" : "Действия: 192.0.2.10", exact: true })
const open = async () => { await action.click(); await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click() }
await open()
const dialog = page.getByRole("dialog")
const comment = dialog.locator('[data-slot="field"]').filter({ has: page.getByText("Комментарий", { exact: true }) }).getByRole("textbox")
await expect(comment).toHaveValue("Исходный комментарий")
await comment.fill("Несохранённый draft"); await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0); await expect(action).toBeFocused()
await open(); await expect(comment).toHaveValue("Исходный комментарий")
await dialog.getByRole("button", { name: "Отмена", exact: true }).click(); await expect(dialog).toHaveCount(0)
await page.getByRole("button", { name: kind === "rule" ? "Новое правило" : "Добавить", exact: true }).click()
await expect(comment).toHaveValue("")
})
}
test("Firewall: удалённый list фильтр сбрасывается и не возвращается после refresh", async ({ page }) => {
const entry = { id: "a1", rosId: "*A1", serverId: "1", serverName: "Роутер", family: "ip", list: "trusted", address: "192.0.2.10", comment: "", disabled: false }
let entries = [entry]
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const p = new URL(r.request().url()).pathname
return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/firewall/all" ? { rules: [], addressLists: entries } : p === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1"); await page.getByRole("button", { name: /Адр. листы/ }).click()
await page.getByRole("button", { name: "trusted", exact: true }).click()
const other = { ...entry, id: "a2", rosId: "*A2", list: "other", address: "192.0.2.20" }
entries = [other]; await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("button", { name: "Действия: 192.0.2.20", exact: true })).toBeVisible()
entries = [entry, other]; await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("button", { name: "Действия: 192.0.2.10", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Действия: 192.0.2.20", exact: true })).toBeVisible()
})
+65
View File
@@ -0,0 +1,65 @@
import { test, expect, type Page } from "@playwright/test"
const first = "2026-09-30T03:00:00.000Z"
const second = "2026-09-30T03:05:00.000Z"
const third = "2026-09-30T03:10:00.000Z"
const rule = { id: "rule-1", rosId: "*1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Правило тест", enabled: true, hits: 1 }
async function setup(page: Page) {
const control: { payload: unknown; fail: boolean } = { payload: { rules: [rule], addressLists: [] }, fail: false }
await page.clock.setFixedTime(new Date(first))
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/firewall/all" && control.fail) return route.fulfill({ status: 503, json: { error: "Сбой обновления тест" } })
return route.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/firewall/all" ? control.payload
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
return control
}
test("Firewall: время успешного среза обновляется, недоступного сохраняется", async ({ page }) => {
const control = await setup(page)
await page.goto("/firewall?server=1")
const freshness = page.getByLabel("Свежесть данных Firewall")
await expect(freshness.locator("time")).toHaveAttribute("datetime", first)
await page.clock.setFixedTime(new Date(second))
control.payload = { rules: [], addressLists: [], failures: [{ serverId: "1", family: "ip", table: "filter", error: "Таблица недоступна тест" }] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
const warning = page.getByRole("alert").filter({ hasText: "Не все срезы" })
await expect(warning.locator("time")).toHaveAttribute("datetime", first)
await expect(freshness.locator("time").first()).toHaveAttribute("datetime", first)
await expect(freshness.locator("time").last()).toHaveAttribute("datetime", second)
await page.clock.setFixedTime(new Date(third))
control.fail = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Сбой обновления тест" })).toBeVisible()
await expect(freshness.locator("time").first()).toHaveAttribute("datetime", first)
await expect(freshness.locator("time").last()).toHaveAttribute("datetime", second)
control.fail = false
control.payload = { rules: [], addressLists: [], failures: [{ serverId: "1", error: "Устройство недоступно тест" }] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(warning.locator("time").first()).toHaveAttribute("datetime", first)
await expect(warning.locator("time").last()).toHaveAttribute("datetime", second)
control.payload = { rules: [], addressLists: [] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(freshness.locator("time")).toHaveCount(1)
await expect(freshness.locator("time")).toHaveAttribute("datetime", third)
await expect(warning).toHaveCount(0)
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
})
test("Firewall: первично недоступный срез не получает время успешного опроса", async ({ page }) => {
const control = await setup(page)
control.payload = { rules: [], addressLists: [], failures: [{ serverId: "1", error: "Устройство недоступно тест" }] }
await page.goto("/firewall?server=1")
const freshness = page.getByLabel("Свежесть данных Firewall")
await expect(freshness).toContainText("Время получения срезов неизвестно")
await expect(freshness).toContainText("Не получено срезов: 5")
await expect(freshness.locator("time")).toHaveCount(0)
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toContainText("Последние данные: ещё не получены")
control.payload = { rules: [], addressLists: [] }
await page.clock.setFixedTime(new Date(second))
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(freshness.locator("time")).toHaveAttribute("datetime", second)
await expect(freshness).not.toContainText("Не получено срезов")
})
+75
View File
@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test"
test("Firewall: восстановление удерживает общий замок до обновления правил и истории", async ({ page }) => {
let attempts = 0
let revisionsReads = 0
let writes = 0
let release: (() => void) | undefined
let releaseRead: (() => void) | undefined
let releaseHistory: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
const readGate = new Promise<void>(resolve => { releaseRead = resolve })
const historyGate = new Promise<void>(resolve => { releaseHistory = resolve })
const rule = { id: "rule-1", rosId: "*R/1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Правило тест", enabled: true, hits: 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 (path.endsWith("/restore")) {
attempts++
expect(decodeURIComponent(path)).toBe("/api/firewall/revisions/rev/1/restore")
expect(route.request().postDataJSON()).toEqual({ serverId: "1" })
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Восстановление запрещено тест" } }) }
return route.fulfill({ json: { ok: true } })
}
if (route.request().method() !== "GET") { writes++; return route.fulfill({ json: { ok: true } }) }
if (path === "/api/firewall/all" && attempts === 2) await readGate
if (path.endsWith("/revisions")) {
revisionsReads++
if (revisionsReads === 2) await historyGate
}
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
: path === "/api/firewall/all" ? { rules: [rule], addressLists: [] }
: path.endsWith("/revisions") ? { revisions: [{ id: "rev/1", serverId: "1", section: "firewall", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await page.getByRole("button", { name: "История", exact: true }).click()
const sheet = page.getByRole("dialog")
await sheet.getByRole("button", { name: "Откатить", exact: true }).click()
const dialog = page.getByRole("alertdialog")
const restore = dialog.getByRole("button", { name: "Откатить", exact: true })
await restore.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
const locked = async () => {
await expect(page.locator("tr[data-row-id]").getByRole("switch", { includeHidden: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "История", exact: true, includeHidden: true })).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Отмена" })).toBeDisabled()
}
await locked()
// Проверяем защиту обработчика открытия формы даже при программном клике за модальным слоем.
await page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true }).evaluate(button => (button as HTMLButtonElement).click())
expect(writes).toBe(0)
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
release!()
await expect(dialog.getByRole("alert").filter({ hasText: "Восстановление запрещено тест" })).toBeVisible()
await expect(page.locator("tr[data-row-id]").getByRole("switch", { includeHidden: true })).toBeEnabled()
await restore.click()
await expect.poll(() => attempts).toBe(2)
await locked()
releaseRead!()
await expect.poll(() => revisionsReads).toBe(2)
await locked()
releaseHistory!()
await expect(dialog).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(sheet).toHaveCount(0)
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled()
await page.getByRole("button", { name: "Действия: forward accept", exact: true }).click()
await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click()
await page.getByRole("dialog").getByRole("button", { name: "Сохранить", exact: true }).click()
await expect.poll(() => writes).toBe(1)
await expect(page.getByRole("dialog")).toHaveCount(0)
})
+81
View File
@@ -0,0 +1,81 @@
import { test, expect } from "@playwright/test"
import type { FirewallRule } from "../../lib/data"
for (const direction of ["down", "up"] as const) {
test(`Firewall: перемещение ${direction} требует подтверждения и блокирует конфликты`, async ({ page }) => {
let rules: FirewallRule[] = Array.from({ length: 3 }, (_, i) => ({ id: `rule-${i + 1}`, rosId: `*${i + 1}`, serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: `Правило ${i + 1}`, enabled: true, hits: 1 }))
let attempts = 0
let otherWrites = 0
let release: (() => void) | undefined
let releaseRead: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
const readGate = new Promise<void>(resolve => { releaseRead = resolve })
const id = direction === "down" ? "rule-1" : "rule-3"
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/firewall/rules/move") {
attempts++
expect(route.request().postDataJSON()).toEqual({ serverId: "1", family: "ip", table: "filter", rosId: direction === "down" ? "*1" : "*3", destinationRosId: direction === "down" ? "*3" : "*2" })
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Перемещение запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
rules = direction === "down" ? [rules[1], rules[0], rules[2]] : [rules[0], rules[2], rules[1]]
return route.fulfill({ json: { ok: true } })
}
if (route.request().method() !== "GET") otherWrites++
if (path === "/api/firewall/all" && attempts === 4) await readGate
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
: path === "/api/firewall/all" ? { rules, addressLists: [] }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(3)
const move = async () => {
const handle = page.locator(`tr[data-row-id="${id}"]`).getByRole("button", { name: "Перетащите для изменения порядка строк", exact: true })
await handle.focus()
await page.keyboard.press("Space")
await expect(handle).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press(direction === "down" ? "ArrowDown" : "ArrowUp")
await expect(page.getByRole("status").filter({ hasText: "over droppable area rule-2" })).toBeVisible()
await page.keyboard.press("Space")
}
const originalOrder = ["rule-1", "rule-2", "rule-3"]
const order = () => rows.evaluateAll(elements => elements.map(e => e.getAttribute("data-row-id")))
await move()
await expect.poll(() => attempts).toBe(1)
const waiting = page.getByRole("status").filter({ hasText: "Ожидание перемещения правила" })
await expect(waiting).toBeVisible()
await expect(page.getByRole("button", { name: "История", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Перетащите для изменения порядка строк", exact: true })).toHaveCount(0)
for (const toggle of await rows.getByRole("switch").all()) await expect(toggle).toBeDisabled()
await rows.first().getByRole("button", { name: "Действия: forward accept", exact: true }).click()
await expect(page.getByRole("menuitem", { name: "Удалить правило", exact: true })).toBeDisabled()
await expect(page.getByRole("menuitem", { name: "Отключить", exact: true })).toBeDisabled()
await page.keyboard.press("Escape")
await page.getByRole("button", { name: "Новое правило", exact: true }).click()
await expect(page.getByRole("dialog")).toHaveCount(0)
expect(attempts).toBe(1)
expect(otherWrites).toBe(0)
release!()
await expect(page.getByRole("alert").filter({ hasText: "Перемещение запрещено тест" })).toBeVisible()
await expect.poll(order).toEqual(originalOrder)
for (let i = 2; i <= 3; i++) {
await move()
await expect.poll(() => attempts).toBe(i)
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил перемещение правила" })).toBeVisible()
await expect.poll(order).toEqual(originalOrder)
}
await move()
await expect.poll(() => attempts).toBe(4)
await expect(waiting).toBeVisible()
for (const toggle of await rows.getByRole("switch").all()) await expect(toggle).toBeDisabled()
releaseRead!()
await expect(waiting).toHaveCount(0)
await expect.poll(order).toEqual(direction === "down" ? ["rule-2", "rule-1", "rule-3"] : ["rule-1", "rule-3", "rule-2"])
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил перемещение" })).toHaveCount(0)
expect(otherWrites).toBe(0)
})
}
+73
View File
@@ -0,0 +1,73 @@
import { test, expect, type Page } from "@playwright/test"
const server = { id: 1, name: "Первый роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }
const rule = { id: "rule-1", rosId: "*1", serverId: "1", serverName: server.name, family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Первое правило", enabled: true, hits: 1 }
const second = { ...rule, id: "rule-2", rosId: "*2", serverId: "2", serverName: "Второй роутер", comment: "Второе правило" }
const address = { id: "address-1", rosId: "*A", serverId: "1", serverName: server.name, family: "ip", list: "trusted", address: "192.0.2.10", comment: "Адрес тест", disabled: false }
async function setup(page: Page) {
const control: { payload: unknown } = { payload: { rules: [rule, second], addressLists: [address] } }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
return route.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/firewall/all" ? control.payload
: path === "/api/servers" ? [server, { ...server, id: 2, name: "Второй роутер", host: "192.0.2.2" }] : {} })
})
return control
}
test("Firewall: сохраняет недоступную таблицу, обновляет другое устройство и восстанавливает пустой срез", async ({ page }) => {
const control = await setup(page)
await page.goto("/firewall")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
control.payload = { rules: [{ ...second, port: "8443" }], addressLists: [], failures: [{ serverId: "1", serverName: server.name, family: "ip", table: "filter", error: "Таблица недоступна тест" }] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы Firewall получены" })).toContainText("Таблица недоступна тест")
await expect(rows).toHaveCount(2)
await expect(page.locator('tr[data-row-id="rule-1"]')).toContainText("443")
await expect(page.locator('tr[data-row-id="rule-2"]')).toContainText("8443")
await expect(page.getByLabel("Сводка Firewall")).toContainText("—")
await page.getByRole("option").filter({ hasText: "Второй роутер" }).click()
await expect(rows).toHaveCount(1)
await expect(rows.getByRole("switch")).toBeEnabled()
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toHaveCount(0)
await page.getByRole("option").filter({ hasText: "Первый роутер" }).click()
await expect(rows.getByRole("switch")).toBeDisabled()
await expect(page.getByRole("button", { name: "История", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeDisabled()
control.payload = { rules: [second], addressLists: [] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(rows).toHaveCount(0)
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toHaveCount(0)
await expect(page.getByLabel("Сводка Firewall")).not.toContainText("—")
})
test("Firewall: address-list сохраняется при сбое своего среза", async ({ page }) => {
const control = await setup(page)
await page.goto("/firewall?server=1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
control.payload = { rules: [rule, second], addressLists: [], failures: [{ serverId: "1", family: "ip", table: "address-list", error: "Адреса недоступны тест" }] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toBeVisible()
await page.getByRole("button", { name: /Адр. листы/ }).click()
await expect(page.getByText(address.address, { exact: true })).toBeVisible()
await expect(page.getByRole("switch")).toBeDisabled()
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled()
control.payload = { rules: [rule, second], addressLists: [] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByText(address.address, { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeEnabled()
})
test("Firewall: первичный сбой устройства не выглядит успешным пустым списком", async ({ page }) => {
const control = await setup(page)
control.payload = { rules: [], addressLists: [], failures: [{ serverId: "1", error: "Устройство недоступно тест" }] }
await page.goto("/firewall?server=1")
await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toContainText("Устройство недоступно тест")
await expect(page.getByRole("status").filter({ hasText: "Данные Firewall пока недоступны" })).toBeVisible()
await expect(page.getByLabel("Сводка Firewall")).toContainText("—")
control.payload = { rules: [rule], addressLists: [] }
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.getByRole("switch")).toBeEnabled()
})
+81
View File
@@ -0,0 +1,81 @@
import { test, expect } from "@playwright/test"
for (const kind of ["rule", "address"] as const) {
for (const editing of [false, true]) {
for (const family of ["ip", "ip6"] as const) {
test(`Firewall: сохранение ${kind}/${editing ? "edit" : "create"}/${family} сохраняет форму при ошибке`, async ({ page }) => {
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
const rule = { id: "rule-1", rosId: "*R/1", serverId: "1", serverName: "Роутер", family, table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Правило тест", enabled: true, hits: 1 }
const entry = { id: "address-1", rosId: "*A/1", serverId: "1", serverName: "Роутер", family, list: "trusted", address: family === "ip" ? "192.0.2.10" : "2001:db8::10", comment: "Адрес тест", disabled: false }
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 (["POST", "PUT"].includes(route.request().method())) {
attempts++
expect(path).toBe(kind === "rule" ? "/api/firewall/rules" : "/api/firewall/address-lists")
expect(route.request().method()).toBe(editing ? "PUT" : "POST")
const payload = route.request().postDataJSON()
expect(payload).toMatchObject({ serverId: "1", family, comment: "Сохранить ввод", disabled: false })
if (kind === "rule") expect(payload).toMatchObject({ table: "filter", action: "accept", chain: editing ? "forward" : "input" })
else expect(payload).toMatchObject({ list: "trusted", address: entry.address })
if (editing) expect(payload.rosId).toBe(kind === "rule" ? "*R/1" : "*A/1")
else expect(payload).not.toHaveProperty("rosId")
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Сохранение запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
return route.fulfill({ json: { ok: true } })
}
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
: path === "/api/firewall/all" ? { rules: [rule], addressLists: [entry] }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(family === "ip" ? 1 : 0)
if (family === "ip6") await page.getByRole("button", { name: "IPv6", exact: true }).click()
if (kind === "address") await page.getByRole("button", { name: /Адр. листы/ }).click()
const open = async () => {
if (editing) {
await page.getByRole("button", { name: kind === "rule" ? "Действия: forward accept" : "Действия: " + entry.address, exact: true }).click()
await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click()
} else {
await page.getByRole("button", { name: kind === "rule" ? "Новое правило" : "Добавить", exact: true }).click()
}
}
await open()
const dialog = page.getByRole("dialog")
const comment = dialog.locator('[data-slot="field"]').filter({ has: page.getByText("Комментарий", { exact: true }) }).getByRole("textbox")
await comment.fill("Сохранить ввод")
if (kind === "address" && !editing) {
await dialog.getByPlaceholder("cdn-bypass").fill("trusted")
await dialog.getByPlaceholder("10.0.0.0/8").fill(entry.address)
}
const submit = dialog.getByRole("button", { name: editing ? "Сохранить" : kind === "rule" ? "Создать правило" : "Добавить", exact: true })
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(dialog.getByRole("status")).toContainText("Ожидание результата сохранения")
await expect(comment).toBeDisabled()
for (const toggle of await dialog.getByRole("switch").all()) await expect(toggle).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Отмена" })).toBeDisabled()
await page.keyboard.press("Escape")
await expect(dialog).toBeVisible()
release!()
await expect(dialog.getByRole("alert")).toContainText("Сохранение запрещено тест")
await expect(comment).toHaveValue("Сохранить ввод")
for (let i = 2; i <= 3; i++) {
await submit.click()
await expect.poll(() => attempts).toBe(i)
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил сохранение")
await expect(comment).toHaveValue("Сохранить ввод")
await expect(page.getByText(kind === "rule" ? editing ? "Правило сохранено" : "Правило создано" : editing ? "Запись сохранена" : "Запись создана", { exact: true })).toHaveCount(0)
}
await submit.click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(4)
await open()
await expect(dialog.getByRole("alert")).toHaveCount(0)
})
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test"
for (const failure of ["http", "invalid"] as const) {
test(`Firewall: ${failure} блокирует кэш до завершения успешного повтора`, async ({ page }) => {
const rule = { id: "r1", rosId: "*1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Правило", enabled: true, hits: 1 }
let failed = false, gated = false, writes = 0, started = false
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async r => {
const p = new URL(r.request().url()).pathname
if (r.request().method() !== "GET") { writes++; return r.fulfill({ json: { ok: true } }) }
if (p === "/api/firewall/all") {
if (gated) { started = true; await gate }
if (failed) return r.fulfill(failure === "http" ? { status: 503, json: { error: "Чтение недоступно stale" } } : { json: { rules: "invalid", addressLists: [] } })
return r.fulfill({ json: { rules: [rule], addressLists: [] } })
}
return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1")
const rows = page.locator("tr[data-row-id]")
const toggle = rows.getByRole("switch", { name: "Включено правило *1", exact: true })
const history = page.getByRole("button", { name: "История", exact: true })
await expect(toggle).toBeEnabled(); await expect(history).toBeEnabled()
failed = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
const status = page.getByRole("status").filter({ hasText: "Изменения Firewall заблокированы" })
await expect(status).toBeVisible(); await expect(rows).toHaveCount(1)
await expect(toggle).toBeDisabled(); await expect(history).toBeDisabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeDisabled()
await rows.getByRole("button", { name: "Действия: forward accept", exact: true }).click()
for (const name of ["Редактировать", "Отключить", "Удалить правило"]) await expect(page.getByRole("menuitem", { name, exact: true })).toBeDisabled()
await page.keyboard.press("Escape")
failed = false; gated = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => started).toBe(true)
await expect(status).toBeVisible(); await expect(toggle).toBeDisabled(); await expect(history).toBeDisabled()
expect(writes).toBe(0)
release!()
await expect(status).toHaveCount(0); await expect(toggle).toBeEnabled(); await expect(history).toBeEnabled()
await expect(rows).toHaveCount(1); expect(writes).toBe(0)
})
}
+112
View File
@@ -0,0 +1,112 @@
import { test, expect, type Page } from "@playwright/test"
import type { FirewallRule } from "../../lib/data"
async function setup(page: Page, failMove = false) {
let rules: FirewallRule[] = Array.from({ length: 12 }, (_, i) => ({
id: `rule-${i + 1}`, rosId: `*${i + 1}`, serverId: "1", serverName: "Тестовый роутер",
family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp",
src: `192.0.2.${i + 1}`, dst: "", port: "443", iface: "", comment: `Правило ${i + 1}`,
enabled: true, hits: i + 1,
}))
const moves: Record<string, unknown>[] = []
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/firewall/rules/move") {
const body = route.request().postDataJSON()
moves.push(body)
if (failMove) return route.fulfill({ status: 500, json: { message: "Ошибка перемещения" } })
const active = rules.find(r => r.rosId === body.rosId)!
rules = rules.filter(r => r !== active)
const destination = rules.findIndex(r => r.rosId === body.destinationRosId)
rules.splice(destination < 0 ? rules.length : destination, 0, active)
return route.fulfill({ json: { ok: true } })
}
return route.fulfill({ json: path === "/api/firewall/all" ? { rules, addressLists: [] }
: path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "", enabled: true, status: "online", latency: 1 }] : {} })
})
await page.goto("/firewall?server=1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(12)
return moves
}
async function moveFirstDown(page: Page) {
const handle = page.locator('tr[data-row-id="rule-1"]').getByRole("button", { name: "Перетащите для изменения порядка строк", exact: true })
await handle.focus()
await page.keyboard.press("Space")
await expect(handle).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("ArrowDown")
await expect(page.locator('[role="status"]')).toContainText("over droppable area rule-2")
await page.keyboard.press("Space")
}
test("Firewall: клавиатурное перемещение подтверждается API и сохраняет все строки", async ({ page }) => {
const moves = await setup(page)
await moveFirstDown(page)
await expect.poll(() => moves.length).toBe(1)
expect(moves[0]).toMatchObject({ serverId: "1", family: "ip", table: "filter", rosId: "*1", destinationRosId: "*3" })
await expect(page.locator("tr[data-row-id]").first()).toHaveAttribute("data-row-id", "rule-2")
await expect(page.locator("tr[data-row-id]").nth(1)).toHaveAttribute("data-row-id", "rule-1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(12)
})
test("Firewall: ошибка перемещения возвращает исходный порядок", async ({ page }) => {
const moves = await setup(page, true)
await moveFirstDown(page)
await expect.poll(() => moves.length).toBe(1)
await expect(page.locator("tr[data-row-id]").first()).toHaveAttribute("data-row-id", "rule-1")
await expect(page.locator("tr[data-row-id]")).toHaveCount(12)
})
test("Firewall: Escape отменяет перенос без API-запроса", async ({ page }) => {
const moves = await setup(page)
await page.locator('tr[data-row-id="rule-1"]').getByRole("button", { name: "Перетащите для изменения порядка строк", exact: true }).focus()
await page.keyboard.press("Space")
await expect(page.locator('tr[data-row-id="rule-1"]').getByRole("button", { name: "Перетащите для изменения порядка строк", exact: true })).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("ArrowDown")
await expect(page.locator('[role="status"]')).toContainText("over droppable area rule-2")
await page.keyboard.press("Escape")
await expect(page.locator("tr[data-row-id]").first()).toHaveAttribute("data-row-id", "rule-1")
expect(moves).toHaveLength(0)
})
test("Firewall: перенос мышью отправляет тот же запрос", async ({ page }) => {
const moves = await setup(page)
const handle = page.locator('tr[data-row-id="rule-1"]').getByRole("button", { name: "Перетащите для изменения порядка строк", exact: true })
await handle.scrollIntoViewIfNeeded()
const start = await handle.boundingBox()
const target = await page.locator('tr[data-row-id="rule-2"]').boundingBox()
if (!start || !target) throw new Error("Строки недоступны для переноса")
const x = start.x + start.width / 2
await page.mouse.move(x, start.y + start.height / 2)
await page.mouse.down()
await page.mouse.move(x, target.y + target.height / 2, { steps: 10 })
await expect(page.locator('[role="status"]')).toContainText("over droppable area rule-2")
await page.mouse.up()
await expect.poll(() => moves.length).toBe(1)
expect(moves[0]).toMatchObject({ rosId: "*1", destinationRosId: "*3" })
await expect(page.locator("tr[data-row-id]").first()).toHaveAttribute("data-row-id", "rule-2")
})
test("Сценарий Firewall: 12 правил, перемещение и удаление в черновике", async ({ page }) => {
await setup(page)
await page.getByRole("button", { name: "Симулятор", exact: true }).click()
await page.getByRole("button", { name: "Новый сценарий", exact: true }).click()
const dialog = page.getByRole("dialog")
for (let i = 0; i < 12; i++) {
await dialog.getByRole("button", { name: "Добавить правило", exact: true }).click()
await dialog.getByRole("button", { name: "Добавить", exact: true }).click()
}
const rows = dialog.locator("tr[data-row-id]")
await expect(rows).toHaveCount(12)
const firstId = await rows.first().getAttribute("data-row-id")
await expect(dialog.getByRole("button", { name: "Поднять правило 1", exact: true })).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Опустить правило 12", exact: true })).toBeDisabled()
await dialog.getByRole("button", { name: "Опустить правило 1", exact: true }).click()
await expect(rows.nth(1)).toHaveAttribute("data-row-id", firstId!)
await dialog.getByRole("button", { name: "Удалить правило 2", exact: true }).click()
await expect(rows).toHaveCount(11)
await expect(dialog.locator(`tr[data-row-id="${firstId}"]`)).toHaveCount(0)
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
})
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test"
for (const enabled of [true, false]) {
test(`Firewall: переключение правила ${enabled} требует подтверждения`, async ({ page }) => {
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let currentEnabled = enabled
const rule = { id: "rule-1", rosId: "*R/1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Правило тест", enabled, hits: 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 (route.request().method() === "PATCH") {
attempts++
expect(path).toBe("/api/firewall/rules")
expect(route.request().postDataJSON()).toMatchObject({ serverId: "1", rosId: "*R/1", family: "ip", table: "filter", disabled: enabled })
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Изменение запрещено тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} })
if (attempts === 3) return route.fulfill({ json: { ok: false } })
currentEnabled = !enabled
return route.fulfill({ json: { ok: true } })
}
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
: path === "/api/firewall/all" ? { rules: [{ ...rule, enabled: currentEnabled }], addressLists: [] }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1")
const row = page.locator("tr[data-row-id]")
await expect(row).toHaveCount(1)
const toggle = row.getByRole("switch")
await toggle.evaluate(element => { (element as HTMLElement).click(); (element as HTMLElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(toggle).toBeDisabled()
await expect(page.getByRole("status").filter({ hasText: "Ожидание изменения правила" })).toBeVisible()
release!()
await expect(page.getByRole("alert").filter({ hasText: "Изменение запрещено тест" })).toBeVisible()
for (let i = 2; i <= 3; i++) {
await toggle.click()
await expect.poll(() => attempts).toBe(i)
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил изменение правила" })).toBeVisible()
await expect(toggle).toHaveAttribute("aria-checked", String(enabled))
}
await toggle.click()
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled))
expect(attempts).toBe(4)
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил" })).toHaveCount(0)
})
}
+8
View File
@@ -0,0 +1,8 @@
export const emptyAlerts={revision:"1".repeat(64),telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
export const alertsRule={id:"rule-fixture",name:"Fixture rule",type:"server",target:"router-fixture",targets:["router-fixture"],groupId:"group-fixture",condition:"перешёл в offline",conditions:["перешёл в offline"],severity:"critical",enabled:true,cooldown:"5м",confirmStabilitySec:5,recoveryMode:"conditional",recoveryStabilitySec:17,chatId:"",lastFiredAt:null}
export const alertsGroup={id:"group-fixture",name:"Fixture group",combineMode:"any",enabled:true,cooldownOverride:null}
export const alertsWithRule={...emptyAlerts,rules:[alertsRule],groups:[alertsGroup],meta:{...emptyAlerts.meta,servers:["router-fixture"],serversDetail:[{name:"router-fixture",host:"192.0.2.1",site:"",country:"",type:""}]}}
export const emptyOutbox={counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20}
export const heldOutbox={...emptyOutbox,counts:{...emptyOutbox.counts,total:1,unknown:1},items:[{id:"held-fixture",resolutionVersion:3,status:"unknown",ruleName:"Held retry fixture",createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T01:00:00Z"}]}
+14
View File
@@ -0,0 +1,14 @@
import { expect, type Page } from "@playwright/test"
import { backup } from "./backups"
export async function openBackup(page: Page) {
await page.goto("/backups"); await page.getByRole("button", { name: "Новый бэкап", exact: true }).first().click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("checkbox", { name: /Выбрать Роутер/ }).check()
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await dialog.getByRole("textbox", { name: "Заметка резервной копии", exact: true }).fill("manual")
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await expect(dialog.getByRole("button", { name: /Снять бэкап/ })).toBeEnabled()
return dialog
}
export function backupJob(id: string, patch: Record<string, unknown> = {}) { return { id, status: "done", serverIds: ["1"], notes: "manual", total: 1, completed: 1, created: [backup], failures: [], requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", ...patch } }
export function backupAck(id: string) { return { jobId: id, status: "queued", serverIds: ["1"], total: 1, completed: 0 } }
+13
View File
@@ -0,0 +1,13 @@
import type { Page } from "@playwright/test"
export { server } from "./users"
import { server } from "./users"
export const backup = { id: "backup-1", serverId: 1, serverName: "router", filename: "live-config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" }
export const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: ["1"] }
export const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false }
export async function setupBackups(page: Page, headers: Record<string, string> = {}) {
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ headers, json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/backups" ? [backup] : path === "/api/backups/schedule" ? schedule : path === "/api/backups/storage" ? storage : {} })
})
}
+2
View File
@@ -0,0 +1,2 @@
import type {BackendBgpSession} from "@/lib/bgp/response"
export function bgpSession(serverId=1,prefixesRx:number|null=321):BackendBgpSession{return {id:"*1",serverId,serverName:"Роутер "+serverId,serverSite:"TEST",serverCountry:"RU",name:"Сессия "+serverId,peerIp:"192.0.2."+serverId,remoteAs:64513,localAs:64512,localId:"192.0.2.100",remoteId:"192.0.2."+serverId,state:"Established",type:"eBGP",uptime:"1h",holdTime:90,keepalive:30,prefixesRx,prefixesTx:2,inputMessages:20,outputMessages:10,capabilities:[],lastError:null}}
+23
View File
@@ -0,0 +1,23 @@
import { expect, type Page } from "@playwright/test"
import { certificateState, server } from "./certificates"
export async function certificateRoutes(page: Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : path === "/api/certificates/renew-settings" ? { enabled: false, intervalSec: 300, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: true } })
})
}
export function issueJob(request: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
return { id: request.requestId, serverId: String(request.serverId), certName: request.certName, domainNames: request.domainNames, status: "done", step: "done", requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", ...overrides }
}
export async function openIssue(page: Page) {
await page.goto("/certificates"); const action = page.getByRole("button", { name: "Выпустить сертификат", exact: true }); await expect(action).toBeEnabled(); await action.click()
const dialog = page.getByRole("dialog", { name: "Выпуск сертификата", exact: true })
await dialog.getByRole("combobox", { name: "Сервер выпуска", exact: true }).selectOption("1")
await dialog.getByRole("textbox", { name: "Имя сертификата", exact: true }).fill("new-cert")
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await dialog.getByRole("textbox", { name: "Основной домен", exact: true }).fill("test.example")
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
return dialog
}
+5
View File
@@ -0,0 +1,5 @@
export { server } from "./users"
export const certificate = { id: "1:*A", rosId: "*A", name: "certificate-live", serverId: "1", commonName: "test.example", sans: [], issuedBy: "—", validFrom: null, validUntil: null, rawValidFrom: null, rawValidUntil: null, daysLeft: null, keySize: null, trusted: null, status: "unknown", usage: [] }
export function certificateState(certificates: unknown[] = [certificate], overrides: Record<string, unknown> = {}) {
return { collectedAt: "2026-10-01T00:00:00Z", servers: [{ serverId: "1", serverName: "Роутер", collectedAt: "2026-10-01T00:00:00Z", certificates, error: null, ...overrides }] }
}
+6
View File
@@ -0,0 +1,6 @@
import { server } from "./users"
export { server }
export const container = {id:"1-*A",rosId:"*A",name:"container-live",serverId:"1",image:"nginx",tag:"alpine",status:"running",envs:[],mounts:[],interfaces:["veth1"],startOnBoot:false,comment:"",cpu:0,memMb:0}
export function state(containers:unknown[]=[container], overrides:Record<string,unknown>={}) {
return {collectedAt:"2026-10-01T00:00:00Z",servers:[{serverId:1,collectedAt:"2026-10-01T00:00:00Z",availability:"available",containers,failures:[],...overrides}]}
}
Loaded 100 of 3835 files, more files were not shown because too many files have changed in this diff. Show more