Files
console-dev/tests/review-style-layout.e2e.test.mjs
T
cneicy 6041d717f7
前端验证 / verify (push) Failing after 15m2s
fix: 修复首页用户 ID 复制按钮换行问题
- 禁止用户 ID 在按钮内拆行,确保长用户名场景下按钮整体换行
- 添加 Chromium 和 Firefox 在不同视口宽度下的布局回归测试
- 更新 CI 浏览器安装配置及测试文档
- 忽略包含生产服务器结构信息的本地运维技能目录
2026-11-06 19:40:42 +08:00

575 lines
20 KiB
JavaScript

/**
* 布局回归补齐旧业务 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:
"<p>这是一段正常帮助正文,用于验证长文章滚动后的布局。</p>".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();
}
}),
);