diff --git a/README.md b/README.md index f757ee9..4107db5 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,8 @@ CHROME_PATH=/absolute/path/to/chrome npm test 离线场景覆盖登录协议、短信倒计时、路由保护、会话失效、双标签页退出、SSH 密钥管理、 请求超时、支付期限及旧登录入口;还覆盖安全组批量规则、旧工单切换、镜像订单重试、 -级联配置、商品字段校验、安全随机密码和三态主题。 +级联配置、商品字段校验、安全随机密码和三态主题;补充维护布局、深色样式隔离、 +上传控件名称、导航键盘焦点及监控图表减动回归。 测试产物保存在被忽略的 `tests/fixtures/runs/`。 详细使用方式见 [测试说明](tests/README.md)。 diff --git a/tests/README.md b/tests/README.md index ba4fc84..73830ac 100644 --- a/tests/README.md +++ b/tests/README.md @@ -38,6 +38,9 @@ CHROME_PATH=/absolute/path/to/chrome npm test - `review-purchase.e2e.test.mjs`:镜像订单未付复用、取消后重建、已付授权延迟不重复收费;级联候选与默认、结算、购物车恢复一致。 - `review-fields.e2e.test.mjs`:常见 ASCII 规则本地拦截、PCRE 专有规则交后端校验;实例随机密码实际提交及安全源失效、无偏采样边界。 - `review-theme.e2e.test.mjs`:认证页三态主题文案、持久化与跟随系统变化。 +- `review-style-layout.e2e.test.mjs`:维护条随内容与视口变化、超长通知内部滚动、帮助/购物车/机型/配置摘要吸顶避让,退出布局后的偏移清理与导航单一键盘焦点。 +- `review-style-fields.e2e.test.mjs`:深色商品说明对比度、提现样式隔离、实名认证与转账凭证上传名称;实际升级弹窗的窄屏网格。 +- `review-style-motion.e2e.test.mjs`:实例监控 Canvas 初始/刷新/加载反馈跟随系统减动偏好,并验证偏好切换及离页后无运行时错误。 可针对单组执行,例如 `node --test tests/review-routes.e2e.test.mjs`;仍需先构建当前源码。 随机密码场景只记录性质与是否一致,不把密码或安全随机值写入验收附件。 diff --git a/tests/review-style-layout.e2e.test.mjs b/tests/review-style-layout.e2e.test.mjs index 4554d6b..2618d95 100644 --- a/tests/review-style-layout.e2e.test.mjs +++ b/tests/review-style-layout.e2e.test.mjs @@ -17,7 +17,7 @@ const maintenanceMessage = async function layoutBoundary( page, - { maintenance = true, content = "help" } = {}, + { maintenance = true, content = "help", message = maintenanceMessage } = {}, ) { await page .context() @@ -33,21 +33,13 @@ async function layoutBoundary( const body = await response.json(); Object.assign(body.data, { maintenance_mode: Number(maintenance), - maintenance_mode_message: maintenanceMessage, + maintenance_mode_message: message, }); return route.fulfill({ response, json: body }); } - if (path === "/menu") - return reply({ - menu: [ - { - id: 10, - name: "查看官网", - menu_type: "custom", - url: "/official-site", - }, - ], - }); + if (["/ticket/status", "/ticket/type"].includes(path)) + return reply({ list: [] }); + if (path === "/ticket/statistic") return reply({}); if (path === "/official_site_cms/content") return reply({ content: {} }); if (path === "/help") return reply({ @@ -202,26 +194,80 @@ test( ); sizes.push({ width, noticeFits: true, headerBelowNotice: true }); } - await page.getByRole("button", { name: "打开菜单", exact: true }).click(); - const menuVisible = await page - .getByRole("button", { name: "关闭菜单", exact: true }) - .evaluate((button) => { - const r = button.getBoundingClientRect(), - hit = document.elementFromPoint( - r.left + r.width / 2, - r.top + r.height / 2, - ); - return button.contains(hit); - }); - assert.ok( - menuVisible, - "The notice must not cover the drawer close button", + // 关闭控件按既有设计只在键盘焦点时显现,因此从键盘入口验证它不被维护条遮挡。 + const open = page.getByRole("button", { name: "打开菜单", exact: true }); + await open.focus(); + await page.keyboard.press("Enter"); + await eventually( + () => + page + .getByRole("button", { name: "关闭菜单", exact: true }) + .evaluate((button) => { + const r = button.getBoundingClientRect(), + hit = document.elementFromPoint( + r.left + r.width / 2, + r.top + r.height / 2, + ); + return button === document.activeElement && button.contains(hit); + }), + Boolean, + "The notice must not cover the focused drawer close button", ); await page.keyboard.press("Escape"); return { sizes, drawerCloseReachable: true }; }), ); +test( + "oversized maintenance notices scroll without pushing navigation off screen", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.setViewportSize({ width: 320, height: 640 }); + await layoutBoundary(page, { message: maintenanceMessage.repeat(30) }); + await login(page, "/security/ssh-keys"); + const banner = page.locator(".maintenance-banner"); + await banner.waitFor(); + const state = await eventually( + () => + banner.evaluate((element) => { + const bounds = element.getBoundingClientRect(), + header = document + .querySelector(".topbar") + .getBoundingClientRect(); + return { + height: bounds.height, + viewportHeight: innerHeight, + scrollable: element.scrollHeight > element.clientHeight, + belowBanner: header.top >= bounds.bottom - 0.5, + navigationVisible: header.bottom < innerHeight, + }; + }), + (state) => state.belowBanner && state.navigationVisible, + "Even a long notice must leave navigation visible below it", + ); + assert.ok(state.scrollable, "All notice text must remain scrollable"); + assert.ok(state.height <= state.viewportHeight * 0.4 + 1); + const endReachable = await banner.evaluate((element) => { + element.scrollTop = element.scrollHeight; + const bounds = element.getBoundingClientRect(), + text = element.querySelector("span").firstChild, + end = document.createRange(); + end.setStart(text, text.length - 1); + end.setEnd(text, text.length); + const last = end.getBoundingClientRect(); + return ( + element.scrollTop > 0 && + last.top >= bounds.top && + last.bottom <= bounds.bottom + 0.5 + ); + }); + assert.ok(endReachable, "The end of a long notice must remain readable"); + return { boundedHeight: true, navigationVisible: true, endReachable }; + }), +); + for (const [name, path, selector] of [ ["help", "/help/article?id=1", ".help-nav"], ["cart", "/cart", ".cart-foot"], @@ -243,6 +289,8 @@ for (const [name, path, selector] of [ .first() .click(); await page.locator(selector).waitFor(); + if (name === "help") + await page.locator(".content-html p").first().waitFor(); await page.evaluate(() => window.scrollTo(0, 500)); const state = await eventually( () => @@ -279,7 +327,7 @@ for (const [name, path, selector] of [ } test( - "console layout releases its measured offsets when navigating to the official site", + "console layout releases its measured offsets after logging out", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { @@ -298,8 +346,11 @@ test( (top) => top > outsideConsoleTop, "Global notifications must clear the maintenance header", ); - await page.getByRole("link", { name: "查看官网", exact: true }).click(); - await page.locator(".official-site").waitFor(); + await page.getByRole("button", { name: "用户菜单", exact: true }).click(); + await page + .getByRole("menuitem", { name: "退出登录", exact: true }) + .click(); + await page.waitForURL((url) => url.pathname === "/login"); await eventually( toastTop, (top) => Math.abs(top - outsideConsoleTop) < 0.5, @@ -307,7 +358,7 @@ test( ); return { notificationClearedConsole: consoleTop > outsideConsoleTop, - officialLayoutRestored: true, + blankLayoutRestored: true, }; }), ); @@ -322,6 +373,7 @@ for (const [path, name] of [ { timeout: 25000 }, (t) => scenario(t, {}, async (page) => { + await layoutBoundary(page, { maintenance: false }); await login(page, path); const links = page.getByRole("link", { name, exact: true }); await links.first().waitFor();