// Local Vite + fixture APIs only. Never connects to devices or a production DB. import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { mkdir, readFile } 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 base = process.env.NETX_TEST_URL || "http://127.0.0.1:5179"; 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 = [], passed = [], requests = [], writes = []; let templateWriteDelay = 0; page.on("pageerror", (e) => errors.push(e.message)); const complex = [{ any: [{ all: [{ any: [{ field: "id", op: "eq", value: 0 }, { field: "id", op: "eq", value: false }] }, { field: "state", op: "not_empty" }] }] }]; const sheet = { sheet_id: "s", title: "业务条目", metric_id: "custom", key_fields: ["id"], iface_fields: ["port"], compare_fields: ["count"], display_fields: ["id", "custom_context"], field_rules: [{ field: "count", compare: "numeric", tolerance: 2 }], row_filters: complex, ignore_port_changes: false }; const simple = { ...sheet, sheet_id: "simple", title: "简单过滤", row_filters: [{ field: "external_state", op: "eq", value: "up,down" }], field_rules: [{ field: "count", compare: "ignore", ignore: true }], compare_fields: [] }; let templates = [{ id: "tpl", name: "验收模板", job_count: 1, metrics: [sheet, simple], iface_normalize_rules: [{ from: "GE", to: "gei" }] }, ...Array.from({ length: 119 }, (_, i) => ({ id: `t-${i}`, name: `备用模板 ${i}`, job_count: 0, metrics: [sheet] }))]; const job = { id: "job", name: "检查范围失效任务", template_id: "tpl", enabled_sheet_ids: ["removed-sheet"], mode: "manual", status: "ready", before_task_id: "before", after_task_id: "after", before_batch_id: "b", after_batch_id: "a" }; await page.route("**/v1/**", async (route) => { const req = route.request(), p = new URL(req.url()).pathname; requests.push(p); let data = { items: [] }; if (p === "/v1/auth/me") data = { user: { id: "fixture", username: "fixture", role: "admin", is_active: true } }; else if (req.method() === "POST" && p.endsWith("/compare/templates")) { writes.push(req.postDataJSON()); const created = { ...req.postDataJSON(), id: `import-${writes.length}`, job_count: 0 }; templates = [created, ...templates]; data = created; } else if (req.method() === "PATCH" && p.includes("/compare/templates/")) { writes.push(req.postDataJSON()); data = { ...req.postDataJSON(), id: "tpl" }; if (templateWriteDelay) await new Promise((resolve) => setTimeout(resolve, templateWriteDelay)); templates = templates.map((t) => t.id === "tpl" ? { ...t, ...data } : t); } else if (req.method() === "PATCH" && p.endsWith("/compare/jobs/job")) { writes.push(req.postDataJSON()); data = { ...job, ...req.postDataJSON() }; } else if (p.endsWith("/compare/templates")) data = { items: templates }; else if (p.endsWith("/compare/metrics")) data = { items: [{ metric_id: "custom", fields: ["id", "port", "count", "state"].map((name) => ({ name, display_name: name, dtype: "string", is_key: name === "id" })) }] }; else if (p.endsWith("/compare/jobs")) data = { items: [job] }; else if (p.endsWith("/tasks")) data = { items: [{ id: "before", ne_name: "PE-OLD" }, { id: "after", ne_name: "PE-NEW" }] }; else if (p.endsWith("/batches")) data = { items: [{ id: p.includes("before") ? "b" : "a", status: "success", started_at: "2026-10-11T00:00:00Z" }, { id: "partial", status: "partial" }, { id: "running", status: "running" }] }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) }); }); const templateRow = () => page.locator(".bs-template-workspace table tbody tr").filter({ has: page.getByText("验收模板", { exact: true }) }); const editor = () => page.getByRole("dialog", { name: "编辑模板", exact: true }); const openEditor = async () => { await templateRow().getByRole("button", { name: "编辑", exact: true }).click(); await editor().waitFor(); await page.waitForTimeout(350); }; try { await page.goto(`${base}/network/cutover/compare-templates`); await templateRow().waitFor(); assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 50); assert.ok(await templateRow().getByRole("button", { name: "删除", exact: true }).isDisabled()); await templateRow().getByText("已被 1 个任务引用", { exact: true }).waitFor(); assert.deepEqual(requests.filter((p) => p.includes("biz-state") && /\/(tasks|mappings|jobs)$/.test(p)), []); passed.push("50 row render bound", "referenced template protected", "template page skips jobs/tasks/mappings"); await page.getByRole("button", { name: "下一页", exact: true }).click(); assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 50); await page.getByRole("button", { name: "下一页", exact: true }).click(); assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 20); await page.getByRole("textbox", { name: "筛选模板名 / metric", exact: true }).fill("验收模板"); await page.waitForFunction(() => document.querySelectorAll(".bs-template-workspace table tbody tr").length === 1); passed.push("pagination", "search resets page"); const download = page.waitForEvent("download"); await templateRow().getByRole("button", { name: "导出", exact: true }).click(); const exported = JSON.parse(await readFile(await (await download).path(), "utf8")); assert.equal(exported.metrics[0].ignore_port_changes, false); assert.deepEqual(exported.metrics[0].row_filters, complex); passed.push("export keeps false port policy", "export keeps complex filters"); await openEditor(); assert.equal(await editor().getByLabel("端口变化策略", { exact: true }).inputValue(), "strict"); await editor().getByText("custom_context", { exact: true }).waitFor(); assert.equal(await editor().locator(".ct-editor__norm").getAttribute("open"), null); await page.screenshot({ path: path.join(output, "compare-template-editor.png"), fullPage: true }); await editor().getByRole("tab", { name: /^行过滤/ }).click(); assert.deepEqual(JSON.parse(await editor().locator(".ct-complex-filters pre").textContent()), complex); assert.equal(await editor().getByRole("button", { name: "+ 或组", exact: true }).count(), 0); templateWriteDelay = 1000; const saving = page.waitForRequest((req) => req.method() === "PATCH" && req.url().endsWith("/compare/templates/tpl")); await editor().getByRole("button", { name: "保存模板", exact: true }).click(); await saving; assert.ok(await editor().locator("[inert]").count()); assert.ok(await editor().getByRole("button", { name: "Close", exact: true }).isDisabled()); await page.keyboard.press("Escape"); assert.ok(await editor().isVisible()); await editor().waitFor({ state: "hidden" }); templateWriteDelay = 0; assert.deepEqual(writes.at(-1).metrics[0].row_filters, complex); assert.equal(writes.at(-1).metrics[0].ignore_port_changes, false); passed.push("strict policy loaded", "custom fields editable", "aliases collapsed", "opaque filters cannot be partially edited", "saving locks editor and dismissal", "save preserves complex filters and port policy"); await openEditor(); await editor().getByLabel("count 容差", { exact: true }).fill("-1"); const writeCount = writes.length; await editor().getByRole("button", { name: "保存模板", exact: true }).click(); await editor().getByRole("alert").waitFor(); assert.equal(writes.length, writeCount); await editor().getByLabel("count 容差", { exact: true }).fill("3"); assert.equal(await editor().getByRole("alert").count(), 0); await editor().getByRole("tab", { name: /^字段规则/ }).focus(); await page.keyboard.press("End"); assert.equal(await editor().getByRole("tab", { name: /^行过滤/ }).getAttribute("aria-selected"), "true"); // Use the sheet nav directly: locale wording is not part of this assertion. await editor().locator(".ct-editor__nav [role=tab]").first().focus(); await page.keyboard.press("End"); assert.equal(await editor().locator(".ct-editor__nav [role=tab]").last().getAttribute("aria-selected"), "true"); await editor().getByRole("tab", { name: /^字段规则/ }).click(); const countRow = editor().locator(".ct-field-table tbody tr").filter({ has: page.getByText("count", { exact: true }) }); await countRow.locator('input[type="checkbox"]').nth(2).check(); assert.equal(await editor().getByLabel("count 方式", { exact: true }).inputValue(), "eq"); await editor().getByRole("tab", { name: /^行过滤/ }).click(); assert.equal(await editor().getByLabel("字段 1.1", { exact: true }).inputValue(), "external_state"); await editor().getByLabel("过滤运算符 1.1", { exact: true }).selectOption("in"); await page.setViewportSize({ width: 390, height: 1000 }); await page.getByText(/^metrics\[0\].count:/).waitFor({ state: "hidden" }); assert.ok(await editor().locator(".ct-editor__main").evaluate((el) => el.scrollWidth <= el.clientWidth + 1)); assert.ok(await editor().locator(".ct-filter-row").evaluate((el) => el.scrollWidth <= el.clientWidth + 1)); const titleBounds = await editor().getByLabel("表标题", { exact: true }).boundingBox(); const actionsBounds = await editor().locator(".ct-editor__pane-head > .btn-row").boundingBox(); assert.ok(titleBounds.y + titleBounds.height <= actionsBounds.y, "mobile actions must not overlap title input"); const saveBounds = await editor().getByRole("button", { name: "保存模板", exact: true }).boundingBox(); assert.ok(saveBounds.x >= 0 && saveBounds.x + saveBounds.width <= 391); await page.screenshot({ path: path.join(output, "compare-template-editor-mobile.png"), fullPage: true }); await editor().getByRole("button", { name: "保存模板", exact: true }).click(); await editor().waitFor({ state: "hidden" }); const updated = writes.at(-1).metrics[1]; assert.deepEqual(updated.row_filters[0].value, ["up", "down"]); assert.ok(updated.compare_fields.includes("count")); assert.ok(!updated.field_rules.some((r) => r.field === "count" && (r.ignore || ["ignore", "skip", "off"].includes(r.compare)))); passed.push("negative tolerance blocks write", "keyboard rule tabs", "keyboard sheet tabs", "re-enable ignored field", "filter-only field visible", "membership converts to array", "390px layout and footer"); await page.setViewportSize({ width: 1440, height: 1000 }); const bad = { name: "无效导入", metrics: [sheet, { ...sheet, sheet_id: "bad", key_fields: [] }] }; const beforeImport = writes.length; await page.locator('input[type="file"]').setInputFiles({ name: "invalid.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(bad)) }); await page.getByText(/metrics\[1\]/).waitFor(); assert.equal(writes.length, beforeImport); const imported = { ...exported, name: "导入验收" }; await page.locator('input[type="file"]').setInputFiles({ name: "valid.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(imported)) }); await page.getByText("模板已导入", { exact: true }).waitFor(); assert.equal(writes.at(-1).metrics[0].ignore_port_changes, false); assert.deepEqual(writes.at(-1).metrics[0].row_filters, complex); passed.push("invalid import never partially writes", "valid import roundtrip"); await page.goto(`${base}/network/cutover/biz-compare`); await page.getByRole("row").filter({ hasText: job.name }).getByRole("button", { name: "详情", exact: true }).click(); await page.getByRole("tab", { name: "任务配置", exact: true }).click(); await page.getByText(/配置中的检查项已被移除或改名,请重新选择检查范围/).waitFor(); assert.ok(await page.getByRole("button", { name: "保存配置", exact: true }).isDisabled()); await page.getByLabel("操作前批次", { exact: true }).locator('option[value="partial"]').waitFor({ state: "attached" }); assert.ok(await page.getByLabel("操作前批次", { exact: true }).locator('option[value="partial"]').isDisabled()); assert.ok(await page.getByLabel("操作后批次", { exact: true }).locator('option[value="running"]').isDisabled()); await page.getByRole("button", { name: "启用当前全部检查项", exact: true }).click(); const savedJob = page.waitForResponse((res) => res.request().method() === "PATCH" && res.url().endsWith("/compare/jobs/job")); await page.getByRole("button", { name: "保存配置", exact: true }).click(); await savedJob; assert.deepEqual(writes.at(-1).enabled_sheet_ids, []); passed.push("missing sheets block save", "incomplete sources disabled", "explicit scope repair"); assert.deepEqual(errors, []); passed.push("no runtime errors"); console.log(JSON.stringify({ passed, output })); } finally { await browser.close(); }