427 lines
16 KiB
JavaScript
427 lines
16 KiB
JavaScript
/** 回归缺口:刷新时列宽、响应式结构和动态列不能猜测;同样五行内容替换骨架不应改变相邻卡片位置。 */
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import {
|
|
backendOrigin,
|
|
eventually,
|
|
login,
|
|
scenario,
|
|
} from "./helpers/console-browser.mjs";
|
|
|
|
// 通过外部 HTTP 延迟实际列表,首屏不得先猜测表头;成功后的刷新则保留原表与数据。
|
|
for (const width of [390, 1440]) {
|
|
test(
|
|
`host columns wait for their schema and survive refresh at ${width}px`,
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
let release;
|
|
let held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let reads = 0;
|
|
let fail = false;
|
|
await page.route(
|
|
(url) =>
|
|
url.origin === backendOrigin &&
|
|
/^\/console\/v1\/mf_cloud\/7[0-9]{2}(?:\/flow)?$/.test(
|
|
url.pathname,
|
|
),
|
|
(route) =>
|
|
route.fulfill({
|
|
json: {
|
|
status: 200,
|
|
msg: "",
|
|
data: {
|
|
image: { name: "Ubuntu 24.04" },
|
|
total: 100,
|
|
used: 20,
|
|
leave: 80,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await page.route(
|
|
`${backendOrigin}/console/v1/home/host?*`,
|
|
async (route) => {
|
|
reads++;
|
|
await held;
|
|
return route.fulfill({
|
|
json: {
|
|
status: fail ? 400 : 200,
|
|
msg: fail ? "本地列配置读取失败" : "",
|
|
data: {
|
|
count: 20,
|
|
list: Array.from({ length: 20 }, (_, index) => ({
|
|
id: 701 + index,
|
|
name: `audit-host-${701 + index}`,
|
|
product_name: "本地验收云服务器",
|
|
module: "mf_cloud",
|
|
status: "Unpaid",
|
|
self_defined_field: { 101: "青岛" },
|
|
})),
|
|
select_field: ["name", "product_name", "status"],
|
|
self_defined_field: [
|
|
{ id: 101, field_name: "部署区域", field_type: "text" },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
);
|
|
try {
|
|
await login(page, "/cloud-servers?m=73");
|
|
await page.locator(".host-list-page").waitFor();
|
|
await page.locator("#__nuxt-loader").waitFor({ state: "detached" });
|
|
assert.equal(await page.locator(".list-table table").count(), 0);
|
|
await page.locator(".list-table .list-empty-title").waitFor();
|
|
assert.equal(
|
|
await page.locator(".list-table").getAttribute("aria-busy"),
|
|
"true",
|
|
);
|
|
release();
|
|
await page.getByText("audit-host-701", { exact: true }).waitFor();
|
|
const table = page.locator(".list-table table");
|
|
const headers = () => table.locator("th").allTextContents();
|
|
const accepted = await headers();
|
|
assert.ok(accepted.includes("部署区域"));
|
|
assert.ok(accepted.includes("名称"));
|
|
for (const absent of [
|
|
"IP",
|
|
"地域",
|
|
"计费方式",
|
|
"自动续费",
|
|
"基础信息",
|
|
])
|
|
assert.equal(accepted.includes(absent), false);
|
|
const identity = await table.elementHandle();
|
|
held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.getByRole("button", { name: "搜索", exact: true }).click();
|
|
await eventually(
|
|
() => reads,
|
|
(count) => count === 2,
|
|
"Refresh reaches HTTP boundary",
|
|
);
|
|
assert.equal(
|
|
await identity.evaluate(
|
|
(element) =>
|
|
element === document.querySelector(".list-table table"),
|
|
),
|
|
true,
|
|
);
|
|
assert.deepEqual(await headers(), accepted);
|
|
assert.equal(
|
|
await page.getByText("audit-host-701", { exact: true }).isVisible(),
|
|
true,
|
|
);
|
|
release();
|
|
await page.locator(".p-datatable-mask").waitFor({ state: "hidden" });
|
|
await page.evaluate(() =>
|
|
window.scrollTo({ top: 500, behavior: "instant" }),
|
|
);
|
|
const savedScroll = await page.evaluate(() => scrollY);
|
|
assert.ok(savedScroll > 300);
|
|
// 真实站内链接离页再返回,复用行缓存但重新取得列元数据;失败不能永久停留在加载态。
|
|
await page
|
|
.locator('nav a[href="/security/ssh-keys"]')
|
|
.evaluate((element) => element.click());
|
|
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
|
fail = true;
|
|
await page.goBack();
|
|
await page
|
|
.getByRole("button", { name: "重新加载", exact: true })
|
|
.waitFor();
|
|
assert.equal(await page.locator(".list-table table").count(), 0);
|
|
assert.equal(
|
|
await page.locator(".list-table").getAttribute("aria-busy"),
|
|
"false",
|
|
);
|
|
assert.equal(
|
|
await page.locator(".host-list-foot").getAttribute("inert"),
|
|
"",
|
|
);
|
|
fail = false;
|
|
await page
|
|
.getByRole("button", { name: "重新加载", exact: true })
|
|
.click();
|
|
await page.getByText("audit-host-701", { exact: true }).waitFor();
|
|
assert.deepEqual(await headers(), accepted);
|
|
await eventually(
|
|
() => page.evaluate(() => scrollY),
|
|
(position) => Math.abs(position - savedScroll) <= 1,
|
|
"Returning after metadata retry restores the saved list position",
|
|
);
|
|
return {
|
|
width,
|
|
speculativeTable: false,
|
|
refreshRetainsTable: true,
|
|
metadataFailureCanRetry: true,
|
|
};
|
|
} finally {
|
|
release();
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const routePath of ["/cloud-servers", "/cloud-servers?m=74"]) {
|
|
test(
|
|
`empty host response without select_field resolves columns at ${routePath}`,
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await page.route(
|
|
`${backendOrigin}/console/v1/${routePath.includes("?") ? "home/host" : "host"}?*`,
|
|
(route) =>
|
|
route.fulfill({
|
|
json: {
|
|
status: 200,
|
|
msg: "",
|
|
data: { list: [], count: 0, server_page: "classified-v1" },
|
|
},
|
|
}),
|
|
);
|
|
await login(page, routePath);
|
|
await page.locator(".list-table table").waitFor();
|
|
await page.locator(".list-table .list-empty-title").waitFor();
|
|
assert.equal(
|
|
await page.locator(".list-table").getAttribute("aria-busy"),
|
|
"false",
|
|
);
|
|
const headers = await page.locator(".list-table th").allTextContents();
|
|
assert.ok(headers.includes("IP"));
|
|
assert.equal(headers.includes("计费方式"), routePath.includes("?"));
|
|
return {
|
|
routePath,
|
|
emptySchemaResolved: true,
|
|
legacyColumnsRetained: true,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const [routePath, endpoint, loadedText] of [
|
|
["/security/ssh-keys", "ssh_key", "audit-01"],
|
|
["/orders", "order", "本地订单布局验收"],
|
|
])
|
|
for (const width of [390, 640, 768, 1440]) {
|
|
test(
|
|
`${routePath} refresh preserves its responsive layout while data loads at ${width}px`,
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
if (endpoint === "order")
|
|
await page.route(`${backendOrigin}/console/v1/order?*`, (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
status: 200,
|
|
msg: "",
|
|
data: {
|
|
count: 1,
|
|
list: [
|
|
{
|
|
id: 7,
|
|
create_time: 1730000000,
|
|
type: "new",
|
|
name: loadedText,
|
|
gateway_name: "本地支付方式",
|
|
status: "Unpaid",
|
|
amount: "20.00",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await login(page, routePath);
|
|
await page.getByText(loadedText, { exact: true }).waitFor();
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.route(
|
|
`${backendOrigin}/console/v1/${endpoint}?*`,
|
|
async (route) => {
|
|
await held;
|
|
return route.fallback();
|
|
},
|
|
);
|
|
const geometry = () =>
|
|
page.locator(".list-table").evaluate((panel) => {
|
|
const table = panel.querySelector("table");
|
|
const header = table.querySelector("thead");
|
|
return {
|
|
panelMinHeight: getComputedStyle(panel).minHeight,
|
|
panelHeight: panel.getBoundingClientRect().height,
|
|
tableWidth: table.getBoundingClientRect().width,
|
|
tableMinWidth: getComputedStyle(table).minWidth,
|
|
tableDisplay: getComputedStyle(table).display,
|
|
headerDisplay: getComputedStyle(header).display,
|
|
columns: [...header.querySelectorAll("th")].map((cell) => ({
|
|
title: cell.textContent.trim(),
|
|
left: cell.getBoundingClientRect().left,
|
|
width: cell.getBoundingClientRect().width,
|
|
})),
|
|
viewport: innerWidth,
|
|
documentWidth: document.documentElement.scrollWidth,
|
|
};
|
|
});
|
|
let loading;
|
|
try {
|
|
// 真正刷新已登录的深链接;响应挂起时读取用户能看见的加载布局。
|
|
await page.reload();
|
|
await page.locator(".p-datatable-mask").waitFor();
|
|
await page.locator("#__nuxt-loader").waitFor({ state: "detached" });
|
|
loading = await geometry();
|
|
} finally {
|
|
release();
|
|
}
|
|
await page.getByText(loadedText, { exact: true }).waitFor();
|
|
await page
|
|
.locator(".p-datatable-mask")
|
|
.waitFor({ state: "detached" });
|
|
const ready = await geometry();
|
|
t.diagnostic(JSON.stringify({ width, loading, ready }));
|
|
for (const dimension of [
|
|
"panelMinHeight",
|
|
"tableDisplay",
|
|
"headerDisplay",
|
|
])
|
|
assert.equal(
|
|
loading[dimension],
|
|
ready[dimension],
|
|
`Loading must not switch ${dimension} before the same table receives data`,
|
|
);
|
|
if (width < 640) {
|
|
assert.ok(
|
|
loading.tableWidth <= width,
|
|
"The loading card must fit the phone",
|
|
);
|
|
assert.equal(loading.headerDisplay, "none");
|
|
} else {
|
|
assert.equal(loading.columns.length, ready.columns.length);
|
|
for (let index = 0; index < loading.columns.length; index++) {
|
|
const before = loading.columns[index];
|
|
const after = ready.columns[index];
|
|
assert.equal(before.title, after.title);
|
|
for (const dimension of ["left", "width"])
|
|
assert.ok(
|
|
Math.abs(before[dimension] - after[dimension]) <= 1,
|
|
`${before.title} ${dimension} changed: ${before[dimension]} -> ${after[dimension]}`,
|
|
);
|
|
}
|
|
}
|
|
assert.equal(loading.documentWidth, loading.viewport);
|
|
assert.equal(ready.documentWidth, ready.viewport);
|
|
// 再次查询复用已显示的数据,不能恢复视口高度占位或另一套表格模式。
|
|
if (endpoint === "ssh_key")
|
|
await page
|
|
.getByRole("button", { name: "搜索", exact: true })
|
|
.click();
|
|
else
|
|
await page
|
|
.getByRole("textbox", { name: "搜索订单", exact: true })
|
|
.press("Enter");
|
|
await eventually(
|
|
() => page.locator(".p-datatable-mask").count(),
|
|
(count) => count === 0,
|
|
"Refreshing a populated table completes",
|
|
);
|
|
return {
|
|
routePath,
|
|
width,
|
|
loadingLayoutMatchesReady: true,
|
|
phoneFits: width >= 640 || loading.tableWidth <= width,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const width of [1440, 390]) {
|
|
test(
|
|
`home list reserves its rendered row geometry at ${width}px`,
|
|
{ timeout: 30000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
|
const path = new URL(route.request().url()).pathname;
|
|
if (![/\/ticket$/, /\/announcement$/].some((end) => end.test(path)))
|
|
return route.fallback();
|
|
await held;
|
|
return route.fulfill({
|
|
json: {
|
|
status: 200,
|
|
msg: "",
|
|
data: {
|
|
count: 5,
|
|
list: Array.from({ length: 5 }, (_, index) => ({
|
|
id: index + 1,
|
|
title: `本地布局验收 ${index + 1}`,
|
|
create_time: 1730000000,
|
|
post_time: 1730000000,
|
|
status: "待回复",
|
|
color: "#0284c7",
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
});
|
|
const geometry = () =>
|
|
page.locator(".home-tickets, .home-notices").evaluateAll((cards) =>
|
|
cards.map((card) => {
|
|
const box = card.getBoundingClientRect();
|
|
return {
|
|
card: card.classList.contains("home-tickets")
|
|
? "tickets"
|
|
: "notices",
|
|
top: box.top + scrollY,
|
|
height: box.height,
|
|
};
|
|
}),
|
|
);
|
|
let before;
|
|
try {
|
|
await login(page, "/");
|
|
await page
|
|
.locator(".home-tickets .home-row-skeleton")
|
|
.first()
|
|
.waitFor();
|
|
await page.evaluate(() => document.fonts.ready);
|
|
// 只比较本次控制的两个响应,避免混入其他独立面板的正常数据替换。
|
|
await page.waitForFunction(
|
|
() =>
|
|
!document.querySelector(
|
|
".home-servers .home-row-skeleton, .home-expiring .home-row-skeleton, .home-referral .p-skeleton",
|
|
),
|
|
);
|
|
before = await geometry();
|
|
} finally {
|
|
release();
|
|
}
|
|
await page.locator(".home-notices .notice-row").last().waitFor();
|
|
await page.locator(".home-tickets .home-row").last().waitFor();
|
|
const after = await geometry();
|
|
for (let index = 0; index < before.length; index++) {
|
|
for (const dimension of ["height", "top"])
|
|
assert.ok(
|
|
Math.abs(before[index][dimension] - after[index][dimension]) <= 1,
|
|
`${before[index].card} ${dimension} changed: ${before[index][dimension]} -> ${after[index][dimension]}`,
|
|
);
|
|
}
|
|
return { width, sameRowGeometryAfterData: true };
|
|
}),
|
|
);
|
|
}
|