/** * 布局回归补齐旧业务 E2E 的视觉盲区:长维护文案、吸顶避让、导航的单一焦点与布局切换清理。 * 所有内容来自生产已有的本地 HTTP 边界;尺寸断言读取实际 DOM,不检查源码形状。 */ import assert from "node:assert/strict"; import { test } from "node:test"; import { firefox } from "playwright"; import { appOrigin, backendOrigin, eventually, login, scenario, } from "./helpers/console-browser.mjs"; const maintenanceMessage = "系统正在升级维护,部分业务暂时不可用,请稍后再试。如有紧急问题请联系客服,我们正在尽快恢复服务。"; // 真实 KKY 实例在“网络与安全”中同时提供安全组和白名单入口,不绕过组件权限/加载条件。 const networkResponses = { "/host/5": { host: { id: 5, product_id: 123, module: "kky_ecs", name: "本地弹性云", status: "Active", billing_cycle: "monthly", upgrade_allowed: true, }, }, "/kky_ecs/5": { cpu: 2, memory: 4, bandwidth: 10, protect: 0, bill_method: 1, ip: "192.0.2.5", ips: ["192.0.2.5"], intranet_ip: "10.0.0.5", username: "root", port: 22, image: { name: "Ubuntu 24.04" }, security_group_id: 1, security_group_name: "本地安全组", }, "/kky_ecs/5/status": { status: "on", rescue: false }, "/kky_ecs/disk/5": { list: [] }, "/kky_ecs/monitor/5": { sample_info: [], cpu: [], memory: [], bandwidth: [], disk: [], }, "/kky_ecs/5/traffic": { billing: { mode: "bandwidth", monthly_quota: 0, unit_price: "0.00" }, usage_status: "available", quota_status: "not_applicable", }, "/domain_whitelist/form": { fields: [ { name: "domains", title: "域名", type: "textarea", required: true }, ], ips: ["192.0.2.5"], need_review: true, }, "/domain_whitelist/record": { list: [], count: 0 }, }; async function layoutBoundary( page, { maintenance = true, content = "help", message = maintenanceMessage } = {}, ) { await page .context() .route(`${backendOrigin}/console/v1/**`, async (route) => { const path = new URL(route.request().url()).pathname.replace( /^\/console\/v1/, "", ); const reply = (data) => route.fulfill({ json: { status: 200, msg: "", data } }); if (content === "network" && Object.hasOwn(networkResponses, path)) return reply(networkResponses[path]); if (path === "/common") { const response = await route.fetch(); const body = await response.json(); Object.assign(body.data, { maintenance_mode: Number(maintenance), maintenance_mode_message: message, }); return route.fulfill({ response, json: body }); } if (["/ticket/status", "/ticket/type"].includes(path)) return reply({ list: [] }); if (path === "/ticket/statistic") return reply({}); if (path === "/official_site_cms/content") return reply({ content: {} }); if (path === "/help") return reply({ list: [ { id: 1, name: "产品帮助", helps: [{ id: 1, title: "维护期间的使用说明" }], }, ], }); if (path === "/help/1") return reply({ help: { id: 1, title: "维护期间的使用说明", content: "

这是一段正常帮助正文,用于验证长文章滚动后的布局。

".repeat( 80, ), create_time: 1700000000, }, }); if (path === "/cart") return reply({ list: Array.from({ length: 14 }, (_, index) => ({ product_id: 123, qty: 1, name: `云产品 ${index + 1}`, config_options: { cpu: index + 1 }, customfield: {}, self_defined_field: {}, })), }); if (path === "/product/123/config_option") return reply({ price_total: "10.00", preview: [{ name: "CPU", value: "2 核" }], }); if (path === "/purchase_limit/check") return reply({ allowed: true, message: "" }); if (path === "/product/123") return reply({ product: { id: 123, name: "可配置产品", module: "idcsmart_common", pay_type: "free", description: "", }, }); if (path === "/idcsmart_common/product/123/configoption") return reply({ common_product: { pay_type: "free" }, configoptions: Array.from({ length: 18 }, (_, index) => ({ id: index + 1, option_name: `扩展资源 ${index + 1}`, option_type: "quantity", is_show: 1, qty_min: 1, qty_max: 10, qty_change: 1, })), }); if (path === "/product/123/self_defined_field/order_page") return reply({ data: [] }); if (path === "/product" && content === "dedicated") return reply({ list: [ { id: 123, name: "物理服务器", module: "mf_dcim", price: "100.00", stock_control: 0, }, ], count: 1, }); if (path === "/product/123/mf_dcim/order_page") return reply({ model_config: Array.from({ length: 12 }, (_, index) => ({ id: index + 1, name: `机型 ${index + 1}`, cpu: "Xeon", memory: "32GB", disk: "1TB", })), data_center: [ { name: "中国", city: [ { name: "上海", area: [{ name: "一区", line: [{ id: 1, name: "BGP" }] }], }, ], }, ], }); if (path === "/product/123/mf_dcim/line/1") return reply({}); return route.fallback(); }); } async function bannerGeometry(page) { return page.locator(".maintenance-banner").evaluate((banner) => { const box = banner.getBoundingClientRect(), range = document.createRange(); range.selectNodeContents(banner.querySelector("span")); const text = range.getBoundingClientRect(), bar = document.querySelector(".topbar").getBoundingClientRect(); return { top: box.top, bottom: box.bottom, height: box.height, textTop: text.top, textBottom: text.bottom, topbarTop: bar.top, topbarBottom: bar.bottom, viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth, }; }); } test( "maintenance text and page offsets follow content height and viewport changes", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.setViewportSize({ width: 320, height: 740 }); await layoutBoundary(page); await login(page, "/security/ssh-keys"); await page.locator(".maintenance-banner").waitFor(); const sizes = []; for (const width of [320, 375, 1440, 320]) { await page.setViewportSize({ width, height: 740 }); const state = await eventually( () => bannerGeometry(page), (state) => state.textTop >= state.top - 0.5 && state.textBottom <= state.bottom + 0.5 && state.topbarTop >= state.bottom - 0.5, "The full notice and topbar must occupy separate visible regions", ); assert.equal( state.scrollWidth, state.viewport, "A maintenance notice must not widen the document", ); sizes.push({ width, noticeFits: true, headerBelowNotice: true }); } // 关闭控件按既有设计只在键盘焦点时显现,因此从键盘入口验证它不被维护条遮挡。 const open = page.getByRole("button", { name: "打开菜单", exact: true }); await open.focus(); await page.keyboard.press("Enter"); await eventually( () => page .getByRole("button", { name: "关闭菜单", exact: true }) .evaluate((button) => { const r = button.getBoundingClientRect(), hit = document.elementFromPoint( r.left + r.width / 2, r.top + r.height / 2, ); return button === document.activeElement && button.contains(hit); }), Boolean, "The notice must not cover the focused drawer close button", ); await page.keyboard.press("Escape"); return { sizes, drawerCloseReachable: true }; }), ); test( "oversized maintenance notices scroll without pushing navigation off screen", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.setViewportSize({ width: 320, height: 640 }); await layoutBoundary(page, { message: maintenanceMessage.repeat(30) }); await login(page, "/security/ssh-keys"); const banner = page.locator(".maintenance-banner"); await banner.waitFor(); const state = await eventually( () => banner.evaluate((element) => { const bounds = element.getBoundingClientRect(), header = document .querySelector(".topbar") .getBoundingClientRect(); return { height: bounds.height, viewportHeight: innerHeight, scrollable: element.scrollHeight > element.clientHeight, belowBanner: header.top >= bounds.bottom - 0.5, navigationVisible: header.bottom < innerHeight, }; }), (state) => state.belowBanner && state.navigationVisible, "Even a long notice must leave navigation visible below it", ); assert.ok(state.scrollable, "All notice text must remain scrollable"); assert.ok(state.height <= state.viewportHeight * 0.4 + 1); const endReachable = await banner.evaluate((element) => { element.scrollTop = element.scrollHeight; const bounds = element.getBoundingClientRect(), text = element.querySelector("span").firstChild, end = document.createRange(); end.setStart(text, text.length - 1); end.setEnd(text, text.length); const last = end.getBoundingClientRect(); return ( element.scrollTop > 0 && last.top >= bounds.top && last.bottom <= bounds.bottom + 0.5 ); }); assert.ok(endReachable, "The end of a long notice must remain readable"); return { boundedHeight: true, navigationVisible: true, endReachable }; }), ); for (const [name, path, selector] of [ ["help", "/help/article?id=1", ".help-nav"], ["cart", "/cart", ".cart-foot"], ["dedicated", "/products/dedicated-server", ".catalog-lines"], ["configuration", "/products/123", ".config-summary-wrap"], ]) { test( `maintenance sticky ${name} clears the actual fixed header`, { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.setViewportSize({ width: 1440, height: 800 }); await layoutBoundary(page, { content: name }); await login(page, path); if (name === "dedicated") await page .getByRole("button", { name: "选择线路", exact: true }) .first() .click(); await page.locator(selector).waitFor(); if (name === "help") await page.locator(".content-html p").first().waitFor(); await page.evaluate(() => window.scrollTo(0, 500)); const state = await eventually( () => page.locator(selector).evaluate((el) => { const r = el.getBoundingClientRect(), bar = document.querySelector(".topbar").getBoundingClientRect(); const hit = document.elementFromPoint(r.left + 10, r.top + 5); return { scrollY, top: r.top, headerBottom: bar.bottom, unobscured: el.contains(hit), sticky: getComputedStyle(el).position, }; }), (state) => state.scrollY > 0 && state.top >= state.headerBottom - 0.5 && state.unobscured, "Sticky content must remain below, not behind, the header", ); assert.equal( state.sticky, "sticky", "The test must exercise the sticky state", ); return { stickyReached: true, headerCleared: true, hitTestUnobscured: true, }; }), ); } test( "console layout releases its measured offsets after logging out", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await page.setViewportSize({ width: 1680, height: 900 }); await layoutBoundary(page); await page.goto(`${appOrigin}/official-site`); await page.locator(".official-site").waitFor(); const toastTop = () => page .locator(".operation-toasts") .evaluate((el) => parseFloat(getComputedStyle(el).top)); const outsideConsoleTop = await toastTop(); await login(page, "/help/article?id=1"); const consoleTop = await eventually( toastTop, (top) => top > outsideConsoleTop, "Global notifications must clear the maintenance header", ); await page.getByRole("button", { name: "用户菜单", exact: true }).click(); await page .getByRole("menuitem", { name: "退出登录", exact: true }) .click(); await page.waitForURL((url) => url.pathname === "/login"); await eventually( toastTop, (top) => Math.abs(top - outsideConsoleTop) < 0.5, "Console geometry must not leak after the console layout unmounts", ); return { notificationClearedConsole: consoleTop > outsideConsoleTop, blankLayoutRestored: true, }; }), ); for (const [path, name] of [ ["/cloud-servers", "订购云服务器"], ["/dedicated-servers", "订购物理服务器"], ["/tickets", "创建工单"], ]) { test( `navigation action has one keyboard stop on ${path}`, { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { await layoutBoundary(page, { maintenance: false }); await login(page, path); const links = page.getByRole("link", { name, exact: true }); await links.first().waitFor(); for (const link of await links.all()) { assert.equal( await link .locator("button,a[href],input,select,textarea,[tabindex='0']") .count(), 0, "A navigation action must not contain another focusable control", ); await link.focus(); await page.keyboard.press("Tab"); assert.equal( await link.evaluate((el) => el.contains(document.activeElement)), false, "Tab must move on from the action, not enter it a second time", ); } return { navigationActions: await links.count(), singleKeyboardStop: true, }; }), ); } test( "instance network navigation has one keyboard stop per action", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { await layoutBoundary(page, { maintenance: false, content: "network" }); await login(page, "/servers/server?id=5"); await page.getByRole("tab", { name: "网络与安全", exact: true }).click(); for (const name of ["前往安全组管理", "前往域名白名单"]) { const link = page.getByRole("link", { name, exact: true }); await link.waitFor(); await link.focus(); await page.keyboard.press("Tab"); assert.equal( await link.evaluate((element) => element.contains(document.activeElement), ), false, "Tab must leave each network navigation action after its first stop", ); } return { navigationActions: 2, singleKeyboardStop: true }; }), ); // 首页用户 ID 按钮继承标题的任意断行时,Firefox 会把 “#” 和数字拆到两行;按钮必须作为整体排版。 // Chromium 下该问题不出现,因此同一场景还要在 Firefox 中验证真实渲染。 async function routeHomeAccount(context) { await context.route(`${backendOrigin}/console/v1/index`, async (route) => { if (route.request().method() !== "GET") return route.fallback(); const response = await route.fetch(); const body = await response.json(); Object.assign(body.data.account, { id: 6, username: "测试用户" }); return route.fulfill({ response, json: body }); }); } async function userIdChipGeometry(page) { const sizes = []; const chip = page.locator(".welcome-title .copy-id"); await chip.waitFor(); for (const width of [1440, 375, 320]) { await page.setViewportSize({ width, height: 900 }); const state = await eventually( () => chip.evaluate((button) => { const text = [...button.childNodes].find( (node) => node.nodeType === Node.TEXT_NODE, ), value = text.textContent, glyph = (index) => { const range = document.createRange(); range.setStart(text, index); range.setEnd(text, index + 1); return range.getBoundingClientRect(); }, first = glyph(value.indexOf("#")), last = glyph(value.length - 1), box = button.getBoundingClientRect(), lineHeight = parseFloat(getComputedStyle(button).lineHeight); return { label: value.trim(), sameLine: Math.abs(first.top - last.top) <= 0.5, singleLineHeight: box.height < lineHeight * 1.5, }; }), (state) => state.sameLine && state.singleLineHeight, "The user id must not wrap inside its copy button", ); assert.equal(state.label, "#6"); sizes.push({ width, singleLine: true }); } return sizes; } test( "home user id chip stays on one line beside the greeting", { timeout: 60000 }, (t) => scenario(t, {}, async (page) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.setViewportSize({ width: 1440, height: 900 }); await routeHomeAccount(page.context()); await login(page, "/"); const chromium = await userIdChipGeometry(page); const browser = await firefox.launch(); try { const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block", }); // 与共享场景相同:Firefox 也只能访问两个本地 origin。 const blocked = []; await context.route("**/*", (route) => { const url = new URL(route.request().url()); if ( !["http:", "https:"].includes(url.protocol) || [appOrigin, backendOrigin].includes(url.origin) ) return route.fallback(); blocked.push( `${route.request().method()} ${url.origin}${url.pathname}`, ); return route.abort("blockedbyclient"); }); await routeHomeAccount(context); const firefoxPage = await context.newPage(); await login(firefoxPage, "/"); const gecko = await userIdChipGeometry(firefoxPage); assert.deepEqual(blocked, [], "Firefox must stay on local origins"); return { chromium, firefox: gecko }; } finally { await browser.close(); } }), );