Files
console-dev/tests/review-style-layout.e2e.test.mjs
T
cneicy 5d080cacec
前端验证 / verify (push) Failing after 15m3s
ci(frontend): 提高前端流水线及端到端测试稳定性
- 使用内部 checkout 镜像,并校验运行环境中的 Node.js 22
- 检测预览服务端口冲突并重试,避免并行测试误连其他进程
- 调整布局测试的视口宽度和溢出断言,兼容经典滚动条
- 增加时钟暂停的时间余量,避免 CI 调度延迟导致测试失败
2026-11-06 20:24:56 +08:00

576 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 布局回归补齐旧业务 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: document.documentElement.clientWidth,
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",
);
// 根元素使用 scrollbar-gutter: stable;Linux/Windows 的经典滚动条预留槽位后,
// 内容宽度会比视口窄 15px,只有 macOS 覆盖式滚动条才会相等。验收只要求不产生横向溢出。
assert.ok(
state.scrollWidth <= state.viewport,
`A maintenance notice must not widen the document: ${state.scrollWidth} > ${state.viewport}`,
);
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();
}
}),
);