/** * 布局回归补齐旧业务 E2E 的视觉盲区:长维护文案、吸顶避让、导航的单一焦点与布局切换清理。 * 所有内容来自生产已有的本地 HTTP 边界;尺寸断言读取实际 DOM,不检查源码形状。 */ import assert from "node:assert/strict"; import { test } from "node:test"; import { appOrigin, backendOrigin, eventually, login, scenario, } from "./helpers/console-browser.mjs"; const maintenanceMessage = "系统正在升级维护,部分业务暂时不可用,请稍后再试。如有紧急问题请联系客服,我们正在尽快恢复服务。"; async function layoutBoundary( page, { maintenance = true, content = "help" } = {}, ) { 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 (path === "/common") { const response = await route.fetch(); const body = await response.json(); Object.assign(body.data, { maintenance_mode: Number(maintenance), maintenance_mode_message: maintenanceMessage, }); return route.fulfill({ response, json: body }); } if (path === "/menu") return reply({ menu: [ { id: 10, name: "查看官网", menu_type: "custom", url: "/official-site", }, ], }); 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 }); } await page.getByRole("button", { name: "打开菜单", exact: true }).click(); const menuVisible = await 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.contains(hit); }); assert.ok( menuVisible, "The notice must not cover the drawer close button", ); await page.keyboard.press("Escape"); return { sizes, drawerCloseReachable: true }; }), ); 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(); 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 when navigating to the official site", { 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("link", { name: "查看官网", exact: true }).click(); await page.locator(".official-site").waitFor(); await eventually( toastTop, (top) => Math.abs(top - outsideConsoleTop) < 0.5, "Console geometry must not leak after the console layout unmounts", ); return { notificationClearedConsole: consoleTop > outsideConsoleTop, officialLayoutRestored: 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 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, }; }), ); }