351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
/**
|
|
* 布局回归补齐旧业务 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:
|
|
"<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 });
|
|
}
|
|
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,
|
|
};
|
|
}),
|
|
);
|
|
}
|