Files
console-dev/tests/review-list-density.e2e.test.mjs
T
2026-11-07 07:08:48 +08:00

377 lines
14 KiB
JavaScript

/**
* 列表视觉回归补齐迁移后的盲区:实际页面密度、主题、空态和手机卡片。
* 只控制既有外部 HTTP 边界;登录、路由、OpenVue 和 CSS 层叠使用生产构建。
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { backendOrigin, login, scenario } from "./helpers/console-browser.mjs";
const created = 1730000000;
const title = "本地列表验收";
const list = (row, empty) => ({
list: empty ? [] : [row],
count: Number(!empty),
});
const pages = [
["SSH keys", "/security/ssh-keys", "table", "audit-key"],
["logbook", "/logbook", "table", title],
["domains", "/security/domains", "table", "example.invalid"],
["groups", "/security/groups", "table", title],
["messages", "/messages", "table", title],
["news", "/news", "article", title],
["notices", "/notices", "article", title],
["tickets", "/tickets", "table", title],
["transfer", "/transfer", "table", title],
["embedded news", "/resources?section=news", "article", title],
["embedded notices", "/resources?section=notices", "article", title],
];
async function listBoundary(page, state) {
await page.route(`${backendOrigin}/console/v1/**`, async (route) => {
const path = new URL(route.request().url()).pathname.replace(
/^\/console\/v1/,
"",
);
const rows = {
"/ssh_key": {
id: 7,
name: "audit-key",
public_key: `ssh-ed25519 ${"A".repeat(180)}`,
finger_print: "SHA256:local-list-check",
},
"/log": {
id: 7,
create_time: created,
description: title,
ip: "192.0.2.7",
},
"/domain_whitelist/record": {
id: 7,
host_id: 5,
host_name: title,
product_name: "本地云产品",
domains: ["example.invalid"],
status: 1,
create_time: created,
can_remove: 0,
},
"/security_group": {
id: 7,
name: title,
description: "规则说明",
rule_num: 2,
host_num: 1,
create_time: created,
},
"/client_care/mail/list": {
id: 7,
title,
type: "product",
read: 0,
create_time: created,
},
"/news": {
id: 7,
title,
excerpt: "文章摘要",
type: "产品",
create_time: created,
},
"/announcement": { id: 7, title, create_time: created },
"/ticket": {
id: 7,
ticket_num: "AUDIT-7",
title,
name: "产品支持",
status: "待回复",
color: "#0284c7",
create_time: created,
post_time: created,
last_reply_time: created,
},
"/host_transfer/host": {
id: 7,
name: title,
product_name: "本地云产品",
dedicate_ip: "192.0.2.7",
renew_amount: "20.00",
billing_cycle_name: "月付",
due_time: created,
},
};
const staticResponses = {
"/domain_whitelist/host": {
list: [{ id: 5, name: title, product_name: "本地云产品" }],
},
"/certification/info": { is_certification: true },
"/account/notification-preferences": {
email_enabled: false,
sms_enabled: false,
email_available: true,
sms_available: false,
},
"/news/type": {
list: [{ id: 1, name: "产品", news_num: Number(!state.empty) }],
},
"/announcement/type": { list: [{ id: 1, name: "平台公告" }] },
"/ticket/status": { list: [{ id: 1, name: "待回复", color: "#0284c7" }] },
"/ticket/type": { list: [{ id: 1, name: "产品支持" }] },
"/ticket/statistic": { 1: Number(!state.empty) },
"/host_transfer/config": { certification: false },
"/host_transfer/transfer_code": { transfer_code: "LOCAL-ONLY-TRANSFER" },
};
const data = Object.hasOwn(rows, path)
? list(rows[path], state.empty)
: staticResponses[path];
if (data === undefined) return route.fallback();
return route.fulfill({ json: { status: 200, msg: "", data } });
});
}
async function measure(page) {
return page.evaluate(() => {
const read = (selector) => {
const element = document.querySelector(selector);
if (!element) return null;
const style = getComputedStyle(element);
return {
gap: style.gap,
padding: style.padding,
background: style.backgroundColor,
weight: style.fontWeight,
fontSize: style.fontSize,
shadow: style.boxShadow,
display: style.display,
};
};
const row = document.querySelector(
".p-datatable-tbody > tr:not(.p-datatable-empty-message)",
);
return {
dark: document.documentElement.classList.contains("app-dark"),
shell: Boolean(document.querySelector(".deni-layout")),
page: read(".list-page"),
toolbar: read(".list-toolbar"),
table: read(".list-table"),
article: read(".news-card, .notice-item"),
header: read(".p-datatable-header-cell"),
cell: read(
".p-datatable-tbody > tr:not(.p-datatable-empty-message) > td",
),
empty: read(".list-empty"),
emptyIcon: read(".list-empty > i"),
emptyTitle: read(".list-empty-title"),
articleCount: document.querySelectorAll(".news-card, .notice-item")
.length,
tableCount: document.querySelectorAll(".list-table .p-datatable").length,
columns: [...document.querySelectorAll(".p-datatable-thead th")].map(
(cell) => ({
title: cell.textContent.trim(),
left: cell.getBoundingClientRect().left,
width: cell.getBoundingClientRect().width,
}),
),
overflowingButtons: [
...document.querySelectorAll(".p-datatable-tbody td button"),
]
.filter((button) => {
const box = button.getBoundingClientRect();
const cell = button.closest("td").getBoundingClientRect();
return (
box.width > 0 &&
box.height > 0 &&
(box.left < cell.left - 1 || box.right > cell.right + 1)
);
})
.map(
(button) =>
button.textContent.trim() || button.getAttribute("aria-label"),
),
rowDisplay: row ? getComputedStyle(row).display : null,
actionLabelLines: [
...document.querySelectorAll(".row-actions .p-button-label"),
].map((label) => {
const range = document.createRange();
range.selectNodeContents(label);
return [...range.getClientRects()].filter((rect) => rect.width > 0)
.length;
}),
visibleCells: row
? [...row.querySelectorAll("td")]
.filter((cell) => getComputedStyle(cell).display !== "none")
.map((cell) => ({
label: cell.getAttribute("data-label"),
text: cell.innerText.trim(),
}))
: [],
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
};
});
}
function luminance(color) {
const channels = color
.match(/[\d.]+/g)
.slice(0, 3)
.map((value) => {
const channel = Number(value) / 255;
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
for (const [name, routePath, kind, visibleText] of pages) {
for (const width of [1440, 390]) {
for (const theme of ["light", "dark"]) {
test(
`${name} keeps shared list density at ${width}px in ${theme}`,
{ timeout: 30000 },
(t) =>
scenario(t, { loginMode: "password-only" }, async (page) => {
await page.setViewportSize({ width, height: 1000 });
await page.emulateMedia({
colorScheme: theme,
reducedMotion: "reduce",
});
const state = { empty: true };
await listBoundary(page, state);
await login(page, routePath);
await page.locator(".list-empty").waitFor();
await page
.locator(".p-datatable-mask")
.waitFor({ state: "hidden" });
await page.waitForFunction(
() =>
!document
.querySelector(".list-empty-title")
?.textContent.includes("加载"),
);
const empty = await measure(page);
state.empty = false;
await page.reload();
const content = page.locator(
kind === "table"
? ".p-datatable-tbody > tr:not(.p-datatable-empty-message)"
: ".news-card, .notice-item",
);
await content.first().waitFor();
await page
.locator(".p-datatable-mask")
.waitFor({ state: "hidden" });
const filled = await measure(page);
const visible = content
.getByText(visibleText, { exact: true })
.first();
assert.equal(await visible.isVisible(), true);
// 容器可能没有直接绘制文字;对比度应取可见业务文字及其实际承载背景。
filled.text = await visible.evaluate((element) => {
const rgb = (color) => color.match(/[\d.]+/g).map(Number);
const layers = [];
for (let owner = element; owner; owner = owner.parentElement)
layers.push(rgb(getComputedStyle(owner).backgroundColor));
let background = [255, 255, 255];
for (const layer of layers.reverse()) {
const alpha = layer[3] ?? 1;
background = background.map(
(channel, index) =>
channel * (1 - alpha) + layer[index] * alpha,
);
}
return {
color: getComputedStyle(element).color,
background: `rgb(${background.join(", ")})`,
};
});
const violations = [];
for (const [stateName, observed] of [
["empty", empty],
["filled", filled],
]) {
if (!observed.shell)
violations.push(`${stateName}: missing shell`);
if (observed.dark !== (theme === "dark"))
violations.push(`${stateName}: wrong theme`);
if (observed.page?.gap !== "16px")
violations.push(`${stateName}: page gap`);
if (observed.toolbar?.gap !== "8px")
violations.push(`${stateName}: toolbar gap`);
if (observed.horizontalOverflow)
violations.push(`${stateName}: document overflow`);
}
if (empty.empty?.gap !== "6px") violations.push("empty: gap");
if (empty.empty?.padding !== "48px 24px")
violations.push("empty: padding");
if (empty.emptyIcon?.fontSize !== "16px")
violations.push("empty: icon density");
if (kind === "table") {
if (filled.overflowingButtons.length)
violations.push(
`filled: buttons outside cell ${filled.overflowingButtons.join(", ")}`,
);
if (width >= 640) {
if (empty.columns.length !== filled.columns.length)
violations.push("filled: column set changed");
for (let index = 0; index < empty.columns.length; index++) {
const before = empty.columns[index];
const after = filled.columns[index];
if (
!after ||
["left", "width"].some(
(dimension) =>
Math.abs(before[dimension] - after[dimension]) > 1,
)
)
violations.push(
`filled: column geometry changed ${before.title}`,
);
}
}
if (filled.tableCount !== 1)
violations.push("filled: missing actual table");
if (filled.header?.background !== "rgba(0, 0, 0, 0)")
violations.push("filled: opaque header");
if (filled.header?.weight !== "500")
violations.push("filled: header weight");
if (filled.header?.padding !== "12px 16px")
violations.push("filled: header padding");
if (filled.table?.shadow !== "none")
violations.push("filled: panel shadow");
if (width > 640 && filled.cell?.padding !== "12px 16px")
violations.push("filled: cell density");
if (width < 640 && !["block", "grid"].includes(filled.rowDisplay))
violations.push("filled: mobile card missing");
const visibleData = filled.visibleCells
.map((cell) => cell.text)
.join(" ");
if (!visibleData.includes(visibleText))
violations.push("filled: card data hidden");
if (filled.actionLabelLines.some((lines) => lines > 1))
violations.push("filled: row action label wraps");
} else {
if (filled.tableCount !== 0)
violations.push("filled: article page became a table");
if (filled.articleCount !== 1)
violations.push("filled: missing article card");
}
const surface = kind === "table" ? filled.table : filled.article;
const background = luminance(surface.background);
const textBackground = luminance(filled.text.background);
const foreground = luminance(filled.text.color);
if (theme === "light" ? background < 0.5 : background > 0.1)
violations.push("filled: surface ignores theme");
if (
(Math.max(textBackground, foreground) + 0.05) /
(Math.min(textBackground, foreground) + 0.05) <
4.5
)
violations.push("filled: unreadable theme contrast");
assert.deepEqual(violations, [], JSON.stringify({ empty, filled }));
return { routePath, width, theme, kind, empty, filled };
}),
);
}
}
}