netx/web/tests/bizState.browser.mjs

274 lines
18 KiB
JavaScript

// Fixture-only browser regression: never contacts a device or real database.
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import path from "node:path";
const require = createRequire(import.meta.url);
const { chromium } = require(process.env.NETX_PLAYWRIGHT_MODULE || "playwright");
const output = path.resolve(process.env.NETX_TEST_OUTPUT || "../docs/reviews/assets");
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true, channel: process.env.NETX_TEST_BROWSER || "chrome" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
const base = process.env.NETX_TEST_URL || "http://127.0.0.1:5179";
let active = false;
let failTasks = false;
let failTask = false;
let failSheet = false;
let slowPurpose = false;
let listDelay = 0;
let inFlight = 0;
let maxInFlight = 0;
let counts = { lists: 0, detail: 0, progress: 0, profiles: 0 };
const metricRequests = [];
let lastDownload = "";
let task = { id: "t", source: "managed", ne_name: "PE-FAST", ne_ip: "10.10.0.1", vendor: "ZTE",
device_type: "ZXROS", status: "paused", collect_running: false, last_error: "", interval_sec: 3600,
retention_days: 30, daily_keep_enabled: false, daily_keep_count: 10,
items: [{ id: "i-bgp", source_profile_id: "zte.bgp", enabled: true, bindings: [] },
{ id: "i-arp", source_profile_id: "zte.arp", enabled: true, bindings: [] }] };
const profiles = [
{ profile_id: "zte.bgp", title: "BGP 邻居", metric_id: "bgp_peer", kind: "collect", collect_lane: "light", command_template: "show bgp summary", placeholders: [] },
{ profile_id: "zte.arp", title: "ARP 条目", metric_id: "arp", kind: "collect", collect_lane: "heavy", command_template: "show arp", placeholders: [] },
];
const rows = Array.from({ length: 120 }, (_, n) => ({ neighbor: `10.2.1.${n}`, vrf: "core", state: n % 10 ? "Established" : "Idle" }));
const batch = (id, status = "success") => ({ id, status, row_count: 120, command_count: 2, message: "",
started_at: "2026-10-10T15:00:00Z", ended_at: status === "running" ? null : "2026-10-10T15:00:10Z",
protected: status === "running" || id === "b", is_baseline: id === "b", alias: id === "b" ? "割接基准" : "" });
const batches = [batch("b"), batch("partial", "partial"), batch("active", "running"), batch("slow-batch"),
...Array.from({ length: 50 }, (_, n) => batch(`history-${n}`))];
const commands = [
{ id: "c1", raw_command: "show bgp summary", metric_id: "bgp_peer", profile_id: "zte.bgp", has_raw: true, parse_status: "ok", row_count: 120, raw_line_count: 124 },
{ id: "cslow", raw_command: "show arp", metric_id: "arp", profile_id: "zte.arp", has_raw: true, parse_status: "ok", row_count: 1, raw_line_count: 4 },
];
const workbook = (id) => ({ ...batch(id), task_id: "t", commands, sheet_count: 2, sheets_with_data: 2,
sheets: [{ metric_id: "bgp_peer", title: "BGP 邻居", row_count: 120, commands: [commands[0]] },
{ metric_id: "arp", title: "ARP 条目", row_count: 1, commands: [commands[1]] }] });
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await page.route("**/v1/**", async (route) => {
const req = route.request();
const url = new URL(req.url());
const p = url.pathname;
let data = {};
if (p === "/v1/auth/me") data = { user: { id: "test", username: "test", role: "admin", is_active: true } };
else if (p === "/v1/biz-state/tasks") {
counts.lists++;
maxInFlight = Math.max(maxInFlight, ++inFlight);
try {
const purpose = url.searchParams.get("purpose") || "";
if (slowPurpose && purpose === "portrait") await pause(900);
if (listDelay) await pause(listDelay);
if (failTasks) { await route.fulfill({ status: 503, json: { detail: "fixture: tasks unavailable" } }); return; }
const all = [{ ...task, collect_running: active }, { ...task, id: "slow", ne_name: "PE-SLOW", purpose: "cutover_hf" },
...Array.from({ length: 118 }, (_, n) => ({ ...task, id: `extra-${n}`, ne_name: `PE-${n}`, purpose: "portrait" }))];
data = { items: purpose ? all.filter((row) => purpose === "portrait" ? row.purpose !== "cutover_hf" : row.purpose === purpose) : all };
} finally { inFlight--; }
} else if (p === "/v1/biz-state/tasks/t" || p === "/v1/biz-state/tasks/slow") {
counts.detail++;
if (p.endsWith("/slow")) await pause(900);
if (failTask) { await route.fulfill({ status: 503, json: { detail: "fixture: config unavailable" } }); return; }
if (req.method() === "PATCH") task = { ...task, ...req.postDataJSON() };
data = p.endsWith("/slow") ? { ...task, id: "slow", ne_name: "PE-SLOW" } : { ...task, collect_running: active };
} else if (p.endsWith("/progress")) { counts.progress++; data = { ...task, collect_running: active }; delete data.items; }
else if (p === "/v1/biz-state/profiles") { counts.profiles++; data = { items: profiles }; }
else if (p.endsWith("/batches") && p.includes("/tasks/")) data = { items: batches.slice(0, Number(url.searchParams.get("limit") || 50)) };
else if (p.includes("/metrics/")) {
metricRequests.push(Object.fromEntries(url.searchParams));
if (p.endsWith("/arp")) await pause(900);
if (failSheet) { await route.fulfill({ status: 503, json: { detail: "fixture: metric unavailable" } }); return; }
const metric = p.split("/").at(-1);
const kw = url.searchParams.get("kw") || "";
const col = url.searchParams.get("column") || "";
const all = metric === "arp" ? [{ ip: "ARP_ONLY", interface: "gei-1/1", mac: "aa:bb" }] : rows;
const filtered = all.filter((row) => JSON.stringify(col ? row[col] : row).toLowerCase().includes(kw.toLowerCase()));
const pageN = Number(url.searchParams.get("page") || 1);
const size = Number(url.searchParams.get("page_size") || 50);
data = { items: filtered.slice((pageN - 1) * size, pageN * size), total: filtered.length, page: pageN,
columns: Object.keys(all[0]).map((key) => ({ key, header: key })) };
} else if (p.endsWith("/raw.txt")) {
lastDownload = p;
await route.fulfill({ status: 200, contentType: "text/plain", headers: { "Content-Disposition": 'attachment; filename="raw.txt"' }, body: "FIXTURE RAW" }); return;
} else if (p.includes("/commands/")) {
if (p.endsWith("/cslow")) await pause(900);
data = { ...commands.find((c) => c.id === p.split("/").at(-1)), batch_id: "b", raw_text: p.endsWith("/cslow") ? "OLD_ARP_LOG" : "CURRENT_BGP_LOG" };
} else if (p.startsWith("/v1/biz-state/batches/")) {
const id = p.split("/").at(-1);
if (id === "slow-batch") await pause(900);
data = workbook(id);
} else data = { items: [] };
await route.fulfill({ status: 200, json: data });
});
const main = page.locator(".bs-monitor-workspace");
const taskPanel = page.locator(".bs-monitor-task");
const book = page.locator(".bs-workbook-modal");
const openTask = async (name = "PE-FAST") => {
await main.getByRole("row").filter({ hasText: name }).getByRole("button", { name: "详情", exact: true }).click();
};
const historyRow = (id) => taskPanel.locator(".bs-monitor-batches tbody tr").filter({ has: page.getByRole("checkbox", { name: id === "b" ? "割接基准" : id, exact: true }) });
const passed = [];
try {
await page.goto(`${base}/network/cutover/biz-state`);
await main.getByRole("row").filter({ hasText: "PE-FAST" }).waitFor();
assert.equal(await main.locator("tbody tr").count(), 50);
await main.getByRole("button", { name: "下一页", exact: true }).click();
assert.equal(await main.locator("tbody tr").count(), 50);
await main.getByRole("button", { name: "上一页", exact: true }).click();
passed.push("bounded task rendering", "task pagination");
await page.screenshot({ path: path.join(output, "biz-state-tasks.png"), fullPage: true });
slowPurpose = true;
await main.getByLabel("任务用途", { exact: true }).selectOption("portrait");
await main.getByLabel("任务用途", { exact: true }).selectOption("cutover_hf");
await main.getByRole("row").filter({ hasText: "PE-SLOW" }).waitFor();
await pause(1100);
assert.equal(await main.locator("tbody tr").count(), 1);
slowPurpose = false;
await main.getByLabel("任务用途", { exact: true }).selectOption("all");
await main.getByRole("row").filter({ hasText: "PE-FAST" }).waitFor();
passed.push("stale purpose response ignored");
failTasks = true;
await main.getByRole("button", { name: "刷新", exact: true }).click();
await main.getByRole("alert").waitFor();
assert.equal(await main.locator("tbody tr").count(), 50);
failTasks = false;
await main.getByRole("button", { name: "重试", exact: true }).click();
await main.getByRole("alert").waitFor({ state: "detached" });
passed.push("list error retains rows", "list retry");
await openTask("PE-SLOW");
await taskPanel.getByText("正在读取任务配置…", { exact: true }).waitFor();
assert.ok(await taskPanel.getByRole("button", { name: "立即采集", exact: true }).isDisabled());
await taskPanel.getByRole("button", { name: "关闭", exact: true }).click();
await openTask();
await taskPanel.getByRole("tab", { name: "监控项", exact: true }).waitFor();
await pause(1100);
assert.equal(await taskPanel.locator(".bs-monitor-heading > span:last-child").textContent(), "PE-FAST");
passed.push("pending config actions disabled", "stale task response ignored");
await taskPanel.getByLabel("采集周期", { exact: true }).fill("9");
await taskPanel.getByLabel("保留天数", { exact: true }).fill("20");
const saved = page.waitForRequest((req) => req.method() === "PATCH" && req.url().endsWith("/tasks/t"));
await taskPanel.getByRole("button", { name: "保存周期", exact: true }).click();
const body = (await saved).postDataJSON();
assert.equal(body.interval_sec, 32400);
assert.equal(body.retention_days, 20);
await taskPanel.getByLabel("采集周期", { exact: true }).waitFor();
await taskPanel.locator(".bs-monitor-task-content").waitFor({ state: "visible" });
await page.waitForFunction(() => !document.querySelector(".bs-monitor-task-content")?.hasAttribute("inert"));
const profilesBefore = counts.profiles;
await taskPanel.locator(".bs-profiles-table input[type=checkbox]").first().click();
await page.waitForFunction(() => !document.querySelector(".bs-monitor-task-content")?.hasAttribute("inert")
&& document.querySelector(".bs-profiles-table input[type=checkbox]")?.checked === false);
assert.ok(!await taskPanel.locator(".bs-profiles-table input[type=checkbox]").first().isChecked());
assert.equal(counts.profiles, profilesBefore);
passed.push("schedule save payload", "profile toggle", "profile cache");
await page.screenshot({ path: path.join(output, "biz-state-config.png"), fullPage: true });
await page.setViewportSize({ width: 390, height: 1000 });
await pause(300);
assert.ok(await taskPanel.locator(".bs-schedule-row").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
assert.ok(await taskPanel.locator(".modal__footer").evaluate((el) => [...el.querySelectorAll("button")].every((button) => {
const bounds = button.getBoundingClientRect();
return bounds.left >= 0 && bounds.right <= window.innerWidth;
})), "configuration footer stays inside viewport");
await page.screenshot({ path: path.join(output, "biz-state-config-mobile.png"), fullPage: true });
await page.setViewportSize({ width: 1440, height: 1000 });
await taskPanel.getByRole("tab", { name: "监控项", exact: true }).focus();
await page.keyboard.press("ArrowRight");
assert.equal(await taskPanel.getByRole("tab", { name: "采集批次", exact: true }).getAttribute("aria-selected"), "true");
assert.equal(await taskPanel.locator(".bs-monitor-batches tbody tr").count(), 54);
assert.ok(await taskPanel.locator(".bs-monitor-batches tbody tr").first().getByRole("checkbox").isDisabled());
assert.ok(await taskPanel.locator(".bs-monitor-batches tbody tr").nth(2).getByRole("checkbox").isDisabled());
passed.push("390px configuration", "keyboard task tabs", "active and baseline batches protected");
await page.screenshot({ path: path.join(output, "biz-state-batches.png"), fullPage: true });
await historyRow("slow-batch").getByRole("button", { name: "详情", exact: true }).click();
await page.locator(".bs-collect-detail-modal").getByRole("button", { name: "关闭", exact: true }).click();
await pause(1100);
assert.equal(await page.locator(".bs-collect-detail-modal").count(), 0);
passed.push("closed collection detail stays closed");
await historyRow("slow-batch").getByRole("button", { name: "查看", exact: true }).click();
await book.getByRole("button", { name: "关闭", exact: true }).click();
await historyRow("b").getByRole("button", { name: "查看", exact: true }).click();
await book.locator(".bs-sheet-table tbody tr").first().waitFor();
await pause(1100);
assert.ok((await book.locator(".bs-id-row__value").textContent()) === "b");
await book.getByRole("tab", { name: /ARP 条目/ }).click();
await book.getByRole("tab", { name: /BGP 邻居/ }).click();
await book.locator(".bs-sheet-table tbody tr").first().waitFor();
await pause(1100);
assert.equal(await book.getByText("ARP_ONLY", { exact: true }).count(), 0);
passed.push("stale workbook response ignored", "stale metric response ignored");
await book.getByRole("button", { name: "下一页", exact: true }).click();
await book.getByRole("cell", { name: "10.2.1.50", exact: true }).waitFor();
metricRequests.length = 0;
await book.getByLabel("筛选当前表…", { exact: true }).fill("10.2.1.2");
await book.getByRole("cell", { name: "10.2.1.2", exact: true }).waitFor();
assert.ok(metricRequests.every((request) => request.page === "1"));
passed.push("search resets page without stale-page request");
failSheet = true;
await book.getByLabel("筛选当前表…", { exact: true }).fill("故障");
await book.getByRole("alert").waitFor();
assert.equal(await book.locator(".bs-sheet-table tbody tr td").count(), 0);
await page.screenshot({ path: path.join(output, "biz-state-error.png"), fullPage: true });
failSheet = false;
await book.getByRole("button", { name: "重试", exact: true }).click();
await book.getByText("本表无数据", { exact: true }).waitFor();
await book.getByRole("button", { name: "清除筛选", exact: true }).click();
await book.getByRole("cell", { name: "10.2.1.0", exact: true }).waitFor();
passed.push("sheet error clears rows", "sheet retry", "clear filters");
await page.screenshot({ path: path.join(output, "biz-state-workbook.png"), fullPage: true });
await page.setViewportSize({ width: 390, height: 1000 });
await pause(300);
assert.ok(await book.locator(".bs-sheet-filter").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
assert.ok(await book.locator(".modal__footer").evaluate((el) => [...el.querySelectorAll("button")].every((button) => {
const bounds = button.getBoundingClientRect();
return bounds.left >= 0 && bounds.right <= window.innerWidth;
})), "workbook footer stays inside viewport");
await page.screenshot({ path: path.join(output, "biz-state-workbook-mobile.png"), fullPage: true });
await page.setViewportSize({ width: 1440, height: 1000 });
passed.push("390px workbook");
await book.getByRole("tab", { name: /^命令/ }).click();
await book.locator(".bs-commands-card").nth(1).getByRole("button", { name: "原始日志", exact: true }).click();
const raw = page.locator(".bs-rawlog-modal");
await raw.getByRole("button", { name: "关闭", exact: true }).click();
await book.locator(".bs-commands-card").first().getByRole("button", { name: "原始日志", exact: true }).click();
await raw.getByText("CURRENT_BGP_LOG", { exact: true }).waitFor();
await pause(1100);
assert.equal(await raw.getByText("OLD_ARP_LOG", { exact: true }).count(), 0);
const download = page.waitForEvent("download");
await raw.getByRole("button", { name: "导出为文本", exact: true }).click();
await download;
assert.equal(lastDownload, "/v1/biz-state/batches/b/commands/c1/raw.txt");
passed.push("stale log response ignored", "log export source");
await raw.getByRole("button", { name: "关闭", exact: true }).click();
await book.getByRole("button", { name: "关闭", exact: true }).click();
await taskPanel.getByRole("button", { name: "关闭", exact: true }).click();
failTask = true;
await openTask();
await taskPanel.getByRole("alert").waitFor();
failTask = false;
await taskPanel.getByRole("button", { name: "重试", exact: true }).click();
await taskPanel.getByRole("tab", { name: "监控项", exact: true }).waitFor();
passed.push("task config retry");
await taskPanel.getByRole("button", { name: "关闭", exact: true }).click();
active = true;
listDelay = 4500;
await page.reload();
await main.getByRole("row").filter({ hasText: "PE-FAST" }).waitFor();
counts.detail = counts.progress = 0;
maxInFlight = inFlight;
await openTask();
await taskPanel.getByLabel("采集周期", { exact: true }).fill("7");
await pause(14500);
assert.ok(maxInFlight <= 1, `overlapping task polls: ${maxInFlight}`);
assert.equal(counts.detail, 1, "polling does not reload task items");
assert.ok(counts.progress >= 1);
assert.equal(await taskPanel.getByLabel("采集周期", { exact: true }).inputValue(), "7");
await taskPanel.getByRole("tab", { name: "采集批次", exact: true }).click();
assert.equal(await taskPanel.locator(".bs-monitor-batches tbody tr").count(), 54);
assert.deepEqual(errors, []);
passed.push("server-started collection polling", "non-overlapping slow polls", "lightweight progress", "unsaved config preserved", "history limit stays 200", "no runtime errors");
console.log(JSON.stringify({ passed, counts, maxInFlight, output }));
} catch (error) {
await page.screenshot({ path: path.join(output, "biz-state-test-failure.png"), fullPage: true });
throw error;
} finally { await browser.close(); }