/** 回归缺口:刷新时列宽、响应式结构和动态列不能猜测;同样五行内容替换骨架不应改变相邻卡片位置。 */ 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 }; }), ); }