/** * 回归缺口:缺测值不能伪造为0,多日采样不能丢掉日期,磁盘输入的可见标签必须命名原生控件。 * 只替换已配置的外部HTTP边界;经真实登录、实例标签、图表指针与磁盘表单完成验收。 */ import assert from "node:assert/strict"; import { test } from "node:test"; import { backendOrigin, eventually, login, scenario, } from "./helpers/console-browser.mjs"; async function instanceBoundary( page, { module = "mf_cloud", value = 50, time } = {}, ) { const state = { value, quotes: [], chartReads: 0 }; const times = [ "2026-11-05 08:00:00", "2026-11-06 08:00:00", "2026-11-07 08:00:00", ]; // 秒级接口按浏览器本地08:00造样本,与字符串时间接口一致,不依赖执行机时区。 const epoch = time === undefined ? await page.evaluate(() => new Date(2026, 10, 5, 8).getTime() / 1000) : time; await page.context().route(`${backendOrigin}/console/v1/**`, (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname.replace(/^\/console\/v1/, ""); const reply = (data) => route.fulfill({ json: { status: 200, msg: "", data } }); if (path === "/host/5") return reply({ host: { id: 5, product_id: 123, module, name: "本地指标验收实例", status: "Active", billing_cycle: "monthly", upgrade_allowed: true, }, }); if (path === "/host/5/view") return reply({ module }); if (path === "/host/5/ip") return reply({ list: [] }); if (path === "/idcsmart_common/host/5/configoption") return reply({ configoptions: [], client_button: [], chart: [{ type: "bandwidth", title: "通用带宽监控", select: [] }], }); if (path === "/idcsmart_common/host/5/provision/status") return reply({ status: "on" }); if (path === "/idcsmart_common/host/5/configoption/chart") { state.chartReads++; return reply({ unit: "Mbps", label: ["入带宽", "出带宽"], list: [ times.map((time) => ({ time, value: state.value })), times.map((time) => ({ time, value: state.value })), ], }); } if (path === `/${module}/5`) return reply({ image: { id: 6, name: "Ubuntu 24.04" }, port: 22, username: "root", cpu: 2, memory: 4, config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 }, }); if (path === `/${module}/5/status`) return reply({ status: "on", rescue: false }); if (path === `/${module}/5/remote_info`) return reply({ username: "root", port: 22 }); if ( path === "/remf_finance/5/snapshot" || path === "/remf_finance/5/backup" ) return reply({ disk: [] }); if (path === `/${module}/5/disk`) return reply({ list: [ { id: 10, name: "验收数据盘", size: 50, type: "SSD", type2: "data", status: 1, }, ], }); if (path === `/${module}/5/flow`) return reply({ total: 100, used: 20, leave: 80 }); if (path === `/${module}/5/real_data`) return reply({ cpu_usage: state.value, memory_usage: state.value }); if (path === `/${module}/5/chart`) { state.chartReads++; if (module === "remf_finance") { const type = url.searchParams.get("type"); return reply({ unit: type === "cpu" || type === "memory" ? "%" : "KB/s", label: [type === "memory" ? "已用" : "采样"], list: [times.map((time) => ({ time, value: state.value }))], }); } return reply({ list: [0, 1, 2].map((index) => ({ time: epoch === null ? null : epoch + index * 86400, value: state.value, total: 100, used: state.value, in_bw: state.value, out_bw: state.value, read_bytes: state.value, write_bytes: state.value, })), }); } if (path === "/kky_ecs/disk/5") return reply({ list: [] }); if (path === "/kky_ecs/5/traffic") return reply({ billing: { mode: "bandwidth", monthly_quota: 0, unit_price: "0.00" }, usage_status: "available", quota_status: "not_applicable", }); if (path === "/kky_ecs/monitor/5") { state.chartReads++; return reply({ sample_info: times.map((timestamp) => ({ timestamp })), cpu: [{ title: "CPU", value: times.map(() => state.value) }], memory: [ { title: "total", name: "total", value: [100, 100, 100] }, { title: "free", name: "free", value: times.map(() => typeof state.value === "number" ? 100 - state.value : state.value, ), }, ], bandwidth: [{ title: "入", value: [50000, 50000, 50000] }], disk: [{ title: "读", value: times.map(() => state.value) }], }); } if (path === `/product/123/${module}/order_page`) return reply({ data_disk: [ { type: "step", min_value: 10, max_value: 500, other_config: { disk_type: "SSD" }, }, ], config: { disk_limit_num: 4 }, }); if ( path === `/${module}/5/disk/resize` || path === `/${module}/5/disk/price` ) { state.quotes.push({ path, body: request.postDataJSON(), authenticated: !!request.headers().authorization, }); return reply({ price: "10.00" }); } return route.fallback(); }); return state; } async function openMonitor(page) { await page.getByRole("tab", { name: "监控", exact: true }).click(); await eventually( () => page.locator(".mon .chart-card").count(), (count) => count === 4, "The real monitor must finish mounting its four metric cards", ); await eventually( () => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(), Boolean, "The current HTTP refresh must finish", ); } async function hoverMeasurement(page, viewport, expectedValue) { await viewport.locator("canvas").waitFor(); const box = await viewport.boundingBox(); assert.ok(box, "The rendered chart must have a visible viewport"); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); return eventually( () => viewport.innerText(), (text) => text.includes(expectedValue), "A real pointer must expose the supplied measurement in the tooltip", ); } test( "overview distinguishes missing live measurements from an observed zero", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await instanceBoundary(page, { value: null }); await login(page, "/servers/server?id=5"); await eventually( async () => (await page.locator(".ov .res .ring-value").count()) + (await page.locator(".ov .resource-unavailable").count()), (count) => count === 2, "Resource measurements must finish loading", ); assert.equal( await page.locator(".ov .res .ring-value").count(), 0, "Null is not a measured0%", ); assert.equal(await page.locator(".ov .resource-unavailable").count(), 2); return { missingLiveMeasurements: 2, fabricatedZeroRings: 0 }; }), ); test( "monitor keeps null blank and malformed samples unavailable instead of drawing zero", { timeout: 45000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); const state = await instanceBoundary(page, { value: null }); await login(page, "/servers/server?id=5"); await openMonitor(page); const observations = []; for (const value of [null, "", " ", "invalid", false]) { if (observations.length) { state.value = value; const before = state.chartReads; await page.getByRole("button", { name: "刷新", exact: true }).click(); await eventually( () => state.chartReads, (count) => count === before + 4, "Refresh must read every metric throughHTTP", ); await eventually( () => page .getByRole("button", { name: "刷新", exact: true }) .isEnabled(), Boolean, "Current measurement request must settle", ); } assert.equal( await page.locator(".mon .chart-viewport canvas").count(), 0, "Missing or malformed values must not become a real measurement curve", ); assert.equal(await page.locator(".mon .chart-empty").count(), 4); observations.push({ input: value, unavailableCharts: 4 }); } return { cases: observations }; }), ); for (const module of ["mf_cloud", "remf_finance", "kky_ecs"]) { test( `real zero ${module} CPU remains visible alongside other metric charts`, { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); await instanceBoundary(page, { module, value: 0 }); await login(page, "/servers/server?id=5"); await eventually( () => page.locator(".ov .res .ring-value").allTextContents(), (values) => values.length === 2 && values.every((value) => value === "0%"), "Actual zero must remain an observed resource value", ); await openMonitor(page); await eventually( () => page.locator(".mon .chart-viewport canvas").count(), (count) => count === 4, "Observed zero measurements must finish drawing every metric chart", ); const tooltip = await hoverMeasurement( page, page.locator(".mon .chart-viewport").first(), "0%", ); return { module, observedZeroRings: 2, renderedMetricCharts: 4, observedCpuZero: true, tooltip, }; }), ); } for (const module of ["mf_cloud", "remf_finance", "kky_ecs"]) { test( `multi-day ${module} monitoring tooltip identifies the sample date`, { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); await instanceBoundary(page, { module }); await login(page, "/servers/server?id=5"); const overviewTooltip = await hoverMeasurement( page, page.locator(".ov .network-chart-viewport"), "50", ); assert.match(overviewTooltip, /2026[-/年]11[-/月]0?6/); await openMonitor(page); if (module !== "kky_ecs") { await page.getByRole("button", { name: "7天", exact: true }).click(); await eventually( () => page .getByRole("button", { name: "刷新", exact: true }) .isEnabled(), Boolean, "Weekly range must finish reading", ); } const tooltips = []; for (const viewport of await page .locator(".mon .chart-viewport") .all()) { const tooltip = await hoverMeasurement(page, viewport, "50"); assert.match( tooltip, /2026[-/年]11[-/月]0?6/, "Equal HH:mm samples on different days require the actual date in the tooltip", ); assert.match(tooltip, /08:00/); tooltips.push(tooltip); } return { module, suppliedMiddleDate: "2026-11-06", overviewTooltip, tooltips, dateIdentifiesSample: true, }; }), ); } test( "KKY overview keeps an absent CPU and free-memory sample unavailable", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await instanceBoundary(page, { module: "kky_ecs", value: null }); await login(page, "/servers/server?id=5"); await eventually( () => page.locator(".ov .resource-unavailable").count(), (count) => count === 2, "Missing CPU and partial memory history cannot fabricate usage", ); assert.equal(await page.locator(".ov .res .ring-value").count(), 0); return { module: "kky_ecs", missingHistoricalMeasurements: 2 }; }), ); test( "KKY overview keeps a successful empty history unavailable", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await instanceBoundary(page, { module: "kky_ecs" }); await page .context() .route(`${backendOrigin}/console/v1/kky_ecs/monitor/5`, (route) => route.fulfill({ json: { status: 200, msg: "", data: { cpu: [], memory: [], bandwidth: [], sample_info: [] }, }, }), ); await login(page, "/servers/server?id=5"); await eventually( () => page.locator(".ov .resource-unavailable").count(), (count) => count === 2, "An empty measurement history is not two observed zero values", ); assert.equal(await page.locator(".ov .res .ring-value").count(), 0); return { module: "kky_ecs", emptyHistory: true, unavailableMeasurements: 2, }; }), ); test( "physical-server monitoring tooltip retains the actual sample date", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); await instanceBoundary(page, { module: "mf_dcim" }); await login(page, "/servers/server?id=5"); await page.getByRole("tab", { name: "监控", exact: true }).click(); await page.getByRole("button", { name: "7 天", exact: true }).click(); const viewport = page.locator(".monitor:not(.compact) .chart-viewport"); await eventually( () => viewport.getAttribute("aria-busy"), (busy) => busy === "false", "Physical-server sampling request must settle", ); const tooltip = await hoverMeasurement(page, viewport, "50"); assert.match( tooltip, /2026[-/年]11[-/月]0?6/, "Physical-server tooltip must identify the day as well as the time", ); assert.match(tooltip, /08:00/); return { module: "mf_dcim", tooltip, dateIdentifiesSample: true }; }), ); for (const input of [" ", false]) { test( `physical-server monitoring rejects non-measurement ${JSON.stringify(input)}`, { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await instanceBoundary(page, { module: "mf_dcim", value: input }); await login(page, "/servers/server?id=5"); await page.getByRole("tab", { name: "监控", exact: true }).click(); const viewport = page.locator(".monitor:not(.compact) .chart-viewport"); await eventually( () => viewport.getAttribute("aria-busy"), (busy) => busy === "false", "Physical-server sampling request must settle", ); assert.equal( await viewport.locator("canvas").count(), 0, "Whitespace and booleans are not bandwidth samples", ); assert.equal( await viewport .getByText("所选时段暂无带宽采样", { exact: true }) .count(), 1, ); return { input, unavailableBandwidthChart: true }; }), ); } test( "physical-server monitoring does not turn absent sample times into 1970", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await instanceBoundary(page, { module: "mf_dcim", time: null }); await login(page, "/servers/server?id=5"); await page.getByRole("tab", { name: "监控", exact: true }).click(); const viewport = page.locator(".monitor:not(.compact) .chart-viewport"); await eventually( () => viewport.getAttribute("aria-busy"), (busy) => busy === "false", "Physical-server sampling request must settle", ); assert.equal( await viewport.locator("canvas").count(), 0, "A value without a sample time must not become a 1970 measurement", ); assert.equal( await viewport .getByText("所选时段暂无带宽采样", { exact: true }) .count(), 1, ); return { missingTimestamp: null, fabricatedEpochChart: false }; }), ); test( "generic-instance charts distinguish unavailable samples from a measured zero stroke", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); const state = await instanceBoundary(page, { module: "idcsmart_common", value: 0, }); await login(page, "/servers/server?id=5"); await page.getByRole("button", { name: "监控", exact: true }).click(); const chart = page.locator(".common-chart"); await chart.locator("canvas").waitFor(); // richText 提示也绘制在 Canvas 内;验收实际图像中的数据线,不读取 ECharts 私有实例或 option。 const measurementPixels = () => chart .locator("canvas") .first() .evaluate((canvas) => { const copy = document.createElement("canvas"); copy.width = canvas.width; copy.height = canvas.height; const context = copy.getContext("2d"); context.drawImage(canvas, 0, 0); const { data } = context.getImageData( 0, 0, copy.width, copy.height, ); const scale = copy.width / canvas.clientWidth; let colored = 0; for ( let y = Math.ceil(48 * scale); y < copy.height - 40 * scale; y++ ) { for ( let x = Math.ceil(60 * scale); x < copy.width - 25 * scale; x++ ) { const index = (y * copy.width + x) * 4; const channels = [ data[index], data[index + 1], data[index + 2], ]; if ( data[index + 3] > 80 && Math.max(...channels) - Math.min(...channels) > 40 ) colored++; } } return colored; }); await eventually( measurementPixels, (pixels) => pixels > 100, "Measured zero must draw a real colored series, not an empty chart", ); const cases = []; for (const value of [" ", false, null, "invalid"]) { state.value = value; const before = state.chartReads; await page.getByRole("button", { name: "刷新", exact: true }).click(); await eventually( () => state.chartReads, (count) => count === before + 1, "Generic monitoring refresh must use the configured HTTP boundary", ); await eventually( () => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(), Boolean, "Generic monitoring refresh must settle", ); await eventually( measurementPixels, (pixels) => pixels === 0, "Unavailable input must not leave a fabricated colored zero curve", ); cases.push({ input: value, hasMeasurementStroke: false }); } return { module: "idcsmart_common", measuredZeroHasStroke: true, unavailableCases: cases, }; }), ); test( "disk resize visible label names the editable capacity and preserves quote inputs", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { const state = await instanceBoundary(page); await login(page, "/servers/server?id=5"); await page.getByRole("tab", { name: "硬盘", exact: true }).click(); await page.getByRole("button", { name: "扩容", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "磁盘扩容", exact: true, }); await dialog.getByRole("spinbutton").waitFor(); assert.equal( await dialog .getByRole("spinbutton", { name: "扩容至", exact: true }) .count(), 1, "Visible resize label must name the native spinbutton", ); const field = dialog.getByRole("spinbutton", { name: "扩容至", exact: true, }); await field.fill("80"); await field.press("Tab"); await eventually( () => state.quotes.at(-1)?.body.resize_data_disk?.[0]?.size, (size) => size === 80, "Keyboard edit must reach the real resize quote", ); assert.equal(state.quotes.at(-1).authenticated, true); return { accessibleName: "扩容至", quotedCapacityGb: 80, authenticatedQuote: true, }; }), ); test( "new disk capacity visible label names its editable field", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { const state = await instanceBoundary(page); await login(page, "/servers/server?id=5"); await page.getByRole("tab", { name: "硬盘", exact: true }).click(); await page .getByRole("button", { name: "购买数据盘", exact: true }) .click(); const dialog = page.getByRole("dialog", { name: "购买数据盘", exact: true, }); await dialog.getByRole("spinbutton").waitFor(); assert.equal( await dialog .getByRole("spinbutton", { name: "容量", exact: true }) .count(), 1, "Visible add-capacity label must name the native spinbutton", ); const field = dialog.getByRole("spinbutton", { name: "容量", exact: true, }); await field.fill("40"); await field.press("Tab"); await eventually( () => state.quotes.at(-1)?.body.add_disk?.[0]?.size, (size) => size === 40, "Keyboard edit must reach the real add-disk quote", ); assert.equal(state.quotes.at(-1).authenticated, true); return { accessibleName: "容量", quotedCapacityGb: 40, authenticatedQuote: true, }; }), );