/** * 列表视觉回归补齐迁移后的盲区:实际页面密度、主题、空态和手机卡片。 * 只控制既有外部 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, 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.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 }; }), ); } } }