Files
console-dev/tests/review-style-motion.e2e.test.mjs
T

313 lines
11 KiB
JavaScript

/**
* 回归缺口:CSS 的减动规则不能关闭 Canvas 动画。通过真实登录、实例监控和刷新,
* 验证初始系统偏好、同一图表的偏好切换,以及离页后的监听清理。
* 仅替换外部 HTTP 与浏览器时钟;比较同轮静态数据的画面,不锁定像素、颜色或动画时长。
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import {
backendOrigin,
eventually,
login,
scenario,
} from "./helpers/console-browser.mjs";
async function monitorBoundary(page) {
const state = {
values: [10, 80, 30],
chartResponses: 0,
holdCharts: false,
pendingCharts: [],
};
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 === "/host/5")
return reply({
host: {
id: 5,
product_id: 123,
module: "mf_cloud",
name: "本地图表验收实例",
status: "Active",
billing_cycle: "monthly",
upgrade_allowed: false,
},
});
if (path === "/host/5/view") return reply({ module: "mf_cloud" });
if (path === "/mf_cloud/5")
return reply({
image: { id: 6, name: "Ubuntu 24.04" },
port: 22,
username: "root",
cpu: 2,
memory: 4,
config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 },
});
if (path === "/mf_cloud/5/status")
return reply({ status: "on", rescue: false });
if (path === "/mf_cloud/5/disk") return reply({ list: [] });
if (path === "/mf_cloud/5/flow")
return reply({ total: 100, used: 20, leave: 80 });
if (path === "/mf_cloud/5/real_data")
return reply({ cpu: 10, memory: 20, disk: 0 });
if (path === "/mf_cloud/5/chart") {
state.chartResponses++;
// 在 HTTP 边界延迟真实刷新,不替换组件的 loading 状态或图表实例。
if (state.holdCharts)
await new Promise((resolve) => state.pendingCharts.push(resolve));
return reply({
list: state.values.map((value, index) => ({
time: 1793894400 + index * 60,
value,
total: 100,
used: value,
in_bw: value * 10,
out_bw: value * 5,
read_bytes: value * 10,
write_bytes: value * 5,
})),
});
}
return route.fallback();
});
return state;
}
async function enterMonitor(page) {
// 必须在首次导航前安装,否则图表库可能缓存未被控制的原生 requestAnimationFrame。
await page.clock.install();
await page.emulateMedia({ reducedMotion: "reduce" });
const state = await monitorBoundary(page);
await login(page, "/servers/server?id=5");
await page.getByRole("tab", { name: "监控", exact: true }).waitFor();
await page.evaluate(() => document.fonts.ready);
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 50));
await page.getByRole("tab", { name: "监控", exact: true }).click();
await eventually(
async () => {
await page.clock.runFor(50);
return page.locator(".chart-viewport canvas").count();
},
(count) => count === 4,
"The real monitor must render all four charts",
);
await eventually(
() => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(),
Boolean,
"Initial chart responses must finish before observing available data",
);
// 先给无动画图表一个绘制帧,不把 Canvas 的创建本身误认为动画。
await page.clock.runFor(50);
await page.mouse.move(0, 0);
return state;
}
async function observeFrames(page, state) {
const sample = () =>
page.locator(".chart-viewport canvas").evaluateAll((canvases) =>
canvases.map((canvas) => {
const image = canvas.toDataURL();
const blank = document.createElement("canvas");
blank.width = canvas.width;
blank.height = canvas.height;
// 不对产品画布反复 getImageData:浏览器可能因此从 GPU 切到 CPU,制造一次性光栅差异。
return {
width: canvas.width,
height: canvas.height,
painted: image !== blank.toDataURL(),
image,
};
}),
);
const first = await sample();
assert.equal(
first.length > 0 && first.every((frame) => frame.painted),
true,
"Stable frames must contain rendered chart feedback, not empty canvases",
);
const responses = state.chartResponses;
let changed = false;
for (const step of [100, 250, 800, 1000]) {
await page.clock.runFor(step);
const next = await sample();
assert.deepEqual(
next.map(({ width, height }) => ({ width, height })),
first.map(({ width, height }) => ({ width, height })),
"Frame comparisons require unchanged canvas dimensions",
);
changed ||= next.some((frame, index) => frame.image !== first[index].image);
}
assert.equal(
state.chartResponses,
responses,
"Stable frame samples must not include another data response",
);
return { changed, chartCount: first.length, dataUnchanged: true };
}
async function refreshMonitor(page, state, values) {
state.values = values;
const before = state.chartResponses;
await page.getByRole("button", { name: "刷新", exact: true }).click();
await eventually(
() => state.chartResponses,
(count) => count === before + 4,
"Refresh must consume new data through all four monitor HTTP requests",
);
await eventually(
() => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(),
Boolean,
"Frame sampling must begin after the refresh response is displayed",
);
await page.clock.runFor(50);
}
test(
"monitor renders stable initial frames for reduced motion",
{ timeout: 45000 },
(t) =>
scenario(t, {}, async (page) => {
const state = await enterMonitor(page);
try {
const observed = await observeFrames(page, state);
assert.equal(
observed.changed,
false,
"Reduced motion must not animate already available chart data",
);
return { initiallyReduced: true, ...observed };
} finally {
await page.clock.resume();
}
}),
);
test(
"monitor loading feedback follows reduced motion without losing refresh state",
{ timeout: 45000 },
(t) =>
scenario(t, {}, async (page) => {
const state = await enterMonitor(page);
try {
await page.clock.runFor(2000);
state.holdCharts = true;
const refresh = page.getByRole("button", { name: "刷新", exact: true });
await refresh.click();
await eventually(
() => state.pendingCharts.length,
(count) => count === 4,
"Loading feedback must be driven by real pending monitor HTTP requests",
);
assert.equal(await refresh.isDisabled(), true);
await page.clock.runFor(50);
const reduced = await observeFrames(page, state);
assert.equal(
reduced.changed,
false,
"Reduced motion must also keep the canvas loading feedback still",
);
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.clock.runFor(50);
const normal = await observeFrames(page, state);
assert.equal(
normal.changed,
true,
"Normal loading feedback may animate",
);
await page.emulateMedia({ reducedMotion: "reduce" });
await page.clock.runFor(50);
const reducedAgain = await observeFrames(page, state);
assert.equal(
reducedAgain.changed,
false,
"Loading feedback must react when reduced motion is enabled again",
);
assert.equal(await refresh.isDisabled(), true);
state.holdCharts = false;
for (const release of state.pendingCharts.splice(0)) release();
await eventually(
() => refresh.isEnabled(),
Boolean,
"Releasing the HTTP responses must finish the real refresh",
);
return {
loadingWhileRequestsPending: true,
reducedLoadingChangedFrames: reduced.changed,
normalLoadingChangedFrames: normal.changed,
reducedAgainLoadingChangedFrames: reducedAgain.changed,
completedRefresh: true,
};
} finally {
state.holdCharts = false;
for (const release of state.pendingCharts.splice(0)) release();
await page.clock.resume();
}
}),
);
test(
"monitor follows motion preference changes and safely unmounts",
{ timeout: 45000 },
(t) =>
scenario(t, {}, async (page) => {
const state = await enterMonitor(page);
try {
await page.clock.runFor(2000);
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.clock.runFor(50);
await refreshMonitor(page, state, [80, 20, 60]);
const normal = await observeFrames(page, state);
assert.equal(
normal.changed,
true,
"Removing reduced motion must restore the normal chart animation",
);
await page.emulateMedia({ reducedMotion: "reduce" });
await page.clock.runFor(50);
await refreshMonitor(page, state, [20, 60, 10]);
const reducedAgain = await observeFrames(page, state);
assert.equal(
reducedAgain.changed,
false,
"Changing the preference back must stop animating refreshed chart data",
);
// 走真实路由离开实例页,再发系统偏好事件;公共场景收集器会拒绝运行时/控制台错误。
await page
.getByRole("link", { name: "首页", exact: true })
.first()
.click();
await eventually(
async () => {
// 导航也依赖浏览器任务/rAF;暂停时钟下只轮询 DOM 会阻止真实路由完成卸载。
await page.clock.runFor(50);
return page.locator(".chart-viewport canvas").count();
},
(count) => count === 0,
"Leaving the instance must unmount the monitor charts",
);
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.clock.runFor(50);
await page.emulateMedia({ reducedMotion: "reduce" });
await page.clock.runFor(50);
return {
normalMotionChangedFrames: normal.changed,
reducedMotionChangedFrames: reducedAgain.changed,
unmountedWithoutErrors: true,
};
} finally {
await page.clock.resume();
}
}),
);