/** * 回归缺口: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); // pauseAt 是跳转而非真实等待;只留 50ms 时,读时间与暂停之间的调度延迟(并行验收或 CI) // 会让目标落在过去并报 “Cannot fast-forward to the past”,因此留出 1 秒余量。 await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); 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(); } }), );