import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { installCustomerFixture } from "./fixtures/customer-mobile.mjs"; const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url); const { chromium } = require("playwright"); const webURL = process.env.VISUAL_WEB_URL ?? "http://127.0.0.1:5173"; const output = resolve(".design-evidence/customer-mobile"); const widths = (process.env.MOBILE_WIDTHS ?? "320,375,390,430,768,900,1024,1440").split(",").map(Number); const sections = ["home", "conversations", "stories", "subjects", "messages", "members", "devices", "sharing", "privacy", "billing", "profile"]; await mkdir(output, { recursive: true }); const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" }); const results = []; const errors = []; async function inspect(page, name) { await page.evaluate(() => document.fonts.ready); const layout = await page.evaluate(() => { const viewport = document.documentElement.clientWidth; const overflows = [...document.querySelectorAll(".customer-page *, .customer-mobile-menu *")].filter((el) => { const rect = el.getBoundingClientRect(); if (!rect.width || !rect.height || getComputedStyle(el).visibility === "hidden") return false; // Only these intentionally scrollable regions may exceed their own viewport. if (el.closest(".usage-heatmap-scroll, .permission-matrix-wrap, .household-tabs, .elder-switcher, .journal-weekly__chart")) return false; return rect.right > viewport + 2 || rect.left < -2 || (el.scrollWidth > el.clientWidth + 2 && getComputedStyle(el).overflowX === "visible" && !["SPAN", "SVG"].includes(el.tagName)); }).slice(0, 16).map((el) => ({ tag: el.tagName, class: String(el.className), width: el.clientWidth, scroll: el.scrollWidth, text: el.textContent?.slice(0, 45) })); return { viewport, pageOverflow: document.documentElement.scrollWidth - viewport, overflows }; }); results.push({ name, ...layout }); if (layout.pageOverflow > 2 || layout.overflows.length) console.log(JSON.stringify({ name, ...layout })); await page.screenshot({ path: resolve(output, `${name}.png`), fullPage: true }); } async function openSection(page, section) { await page.goto(`${webURL}/?section=${section}`); await page.locator(".household-tabs button").first().waitFor(); await page.waitForTimeout(200); } async function checkDialogs(page, width) { const cases = [ ["members", "new-household", () => page.getByRole("button", { name: "新增家庭", exact: true })], ["members", "edit-household", () => page.getByRole("button", { name: "编辑家庭名称" })], ["members", "accept-invitation", () => page.locator(".family-accept-button")], ["members", "invite-member", () => page.locator(".family-member-card--invite")], ["subjects", "new-subject", () => page.locator(".subject-add-button")], ["subjects", "edit-subject", () => page.locator("button.subject-profile-card").first()], ["subjects", "subject-operations", () => page.getByRole("button", { name: "资料与授权", exact: true })], ["devices", "bind-device", () => page.locator(".workspace__actions button").filter({ hasText: "绑定设备" })], ["devices", "device-settings", () => page.locator(".device-card__manage").first()], ["stories", "story-review", () => page.locator(".story-candidate-card").first()], ["stories", "memory-review", () => page.locator(".confirmed-memory-card > button").first()], ["sharing", "new-share", () => page.locator(".subject-list-toolbar > button").filter({ hasText: "新建分享" })], ["privacy", "correction", () => page.getByRole("button", { name: "资料更正", exact: true })], ["privacy", "delete-confirmation", () => page.getByRole("button", { name: /申请.*删除|永久删除|删除长者数据|删除全部数据|主体删除/ }).first()], ["privacy", "access-confirmation", () => page.getByRole("button", { name: "提交全量盘点", exact: true })], ["privacy", "withdraw-consents", () => page.getByRole("button", { name: "批量撤回授权", exact: true })], ["privacy", "export-confirmation", () => page.getByRole("button", { name: "生成私有导出", exact: true })], ]; for (const [section, name, trigger] of cases) { await openSection(page, section); await trigger().click(); const dialog = page.locator(".workspace-dialog:visible, .subject-create-modal:visible, .subject-detail-modal:visible, .subject-management-operations[open]").last(); await dialog.waitFor(); await page.waitForTimeout(100); await inspect(page, `${width}-dialog-${name}`); const bounds = await dialog.evaluate((el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: window.innerHeight }; }); assert.ok(bounds.top >= -1 && bounds.bottom <= bounds.height + 1, `${name}: dialog must fit vertically`); const controls = dialog.locator("button:visible"); await controls.last().scrollIntoViewIfNeeded(); const last = await controls.last().boundingBox(); assert.ok(last && last.y >= 0 && last.y + last.height <= page.viewportSize().height + 1, `${name}: last action must be reachable`); const close = dialog.getByRole("button", { name: /^(关闭.*|取消)$/ }).first(); await close.click(); await dialog.waitFor({ state: "hidden" }); } await openSection(page, "conversations"); await page.locator(".conversation-row").first().click(); await page.getByRole("button", { name: "查看完整聊天", exact: true }).click(); await inspect(page, `${width}-dialog-conversation`); const conversation = page.getByRole("dialog"); await conversation.getByRole("button", { name: "关闭", exact: true }).click(); if (width <= 600) { await page.getByRole("button", { name: "返回聊天列表" }).click(); await page.getByRole("button", { name: "更多筛选" }).click(); await page.getByLabel("开始日期", { exact: true }).fill("2026-08-01"); await inspect(page, `${width}-expanded-conversation-filters`); } } async function checkStates() { for (const [name, options, expected] of [ ["login", { authenticated: false }, "登录家庭陪伴中心"], ["empty", { empty: true }, "还没有可访问的家庭"], ["error", { failure: true }, "家庭数据载入失败"], ]) { const context = await browser.newContext({ viewport: { width: 320, height: 640 }, isMobile: true, hasTouch: true }); const unexpected = await installCustomerFixture(context, options); const page = await context.newPage(); page.on("pageerror", (e) => errors.push(String(e))); await page.goto(webURL); await page.getByText(expected, { exact: true }).waitFor(); await inspect(page, `320-state-${name}`); errors.push(...unexpected); await context.close(); } const context = await browser.newContext({ viewport: { width: 844, height: 390 }, isMobile: true, hasTouch: true }); const unexpected = await installCustomerFixture(context); const page = await context.newPage(); page.on("pageerror", (e) => errors.push(String(e))); for (const section of sections) { await page.goto(`${webURL}/?section=${section}`); await page.locator(".household-tabs button").first().waitFor(); await page.waitForTimeout(150); await inspect(page, `landscape-${section}`); } await openSection(page, "sharing"); await page.locator(".subject-list-toolbar > button").click(); await page.setViewportSize({ width: 390, height: 400 }); const dialog = page.getByRole("dialog"); await dialog.getByLabel("分享用途").fill("和家人一起回顾长者的珍贵记忆"); await dialog.getByRole("button", { name: "下一步确认" }).scrollIntoViewIfNeeded(); await inspect(page, "390-short-viewport-form"); const action = await dialog.getByRole("button", { name: "下一步确认" }).boundingBox(); assert.ok(action && action.y >= 0 && action.y + action.height <= 400, "Form action remains reachable in a short viewport"); await page.goto(`${webURL}/?designPreview=1`); await inspect(page, "390-design-preview"); errors.push(...unexpected); await context.close(); } async function checkNavigation(page, width) { await openSection(page, "home"); const toggle = page.getByRole("button", { name: "打开导航菜单" }); assert.equal(await toggle.getAttribute("aria-expanded"), "false"); await toggle.click(); const drawer = page.locator(".customer-mobile-menu"); await drawer.getByRole("navigation").waitFor(); await page.waitForFunction(() => { const panel = document.querySelector(".customer-mobile-menu .ant-drawer-content-wrapper"); return panel && Math.abs(panel.getBoundingClientRect().left) < 1; }); await inspect(page, `${width}-navigation`); assert.equal(await drawer.getByRole("menuitem").count(), 12); await page.keyboard.press("Escape"); await drawer.getByRole("navigation").waitFor({ state: "hidden" }); assert.equal(await toggle.evaluate((el) => el === document.activeElement), true, "Drawer restores focus"); await toggle.click(); await drawer.getByRole("menuitem", { name: /数据与授权$/ }).click(); await page.locator(".customer-page--privacy").waitFor(); await drawer.getByRole("navigation").waitFor({ state: "hidden" }); await page.goBack(); await page.locator(".customer-page--home").waitFor(); await toggle.click(); const search = drawer.getByPlaceholder("搜索长者、设备、通话"); await search.fill("设备"); await search.press("Enter"); await page.locator(".customer-page--devices").waitFor(); await drawer.getByRole("navigation").waitFor({ state: "hidden" }); await toggle.click(); await drawer.locator(".ant-drawer-mask").click({ position: { x: width - 5, y: 100 }, force: true }); await drawer.getByRole("navigation").waitFor({ state: "hidden" }); await toggle.click(); await page.setViewportSize({ width: 1280, height: 900 }); await page.locator(".customer-sider").waitFor(); await drawer.getByRole("navigation").waitFor({ state: "hidden" }); await page.setViewportSize({ width, height: 844 }); assert.equal(await toggle.getAttribute("aria-expanded"), "false", "Resize leaves the mobile menu closed"); } try { for (const width of widths) { const context = await browser.newContext({ viewport: { width, height: width < 600 ? 844 : 1024 }, isMobile: width < 600, hasTouch: width <= 900, reducedMotion: "reduce" }); const unexpected = await installCustomerFixture(context); const page = await context.newPage(); page.setDefaultTimeout(8000); page.on("pageerror", (e) => errors.push(String(e))); for (const section of process.env.MOBILE_INTERACTIONS_ONLY ? [] : process.env.MOBILE_SECTIONS?.split(",") ?? sections) { await page.goto(`${webURL}/?section=${section}`); await page.locator(".customer-account-button").waitFor(); if (section !== "profile") await page.locator(".household-tabs button").first().waitFor(); await page.waitForTimeout(200); if (section === "conversations") { await page.locator(".conversation-row").first().click(); await page.locator(".memory-summary-panel").waitFor(); await page.waitForTimeout(150); } if (section === "billing" && width <= 600) { const scroll = await page.locator(".usage-heatmap-scroll").evaluate((el) => ({ left: el.scrollLeft, width: el.clientWidth, full: el.scrollWidth })); assert.ok(scroll.left + scroll.width >= scroll.full - 2, "Mobile calendar opens at the latest dates"); await page.locator(".usage-heatmap button").last().click(); assert.equal(await page.locator(".usage-heatmap button.selected").count(), 1); } await inspect(page, `${width}-${section}`); } if (!process.env.MOBILE_SECTIONS && [320, 390, 768].includes(width)) await checkDialogs(page, width); if (!process.env.MOBILE_SECTIONS && width === 390) await checkNavigation(page, width); errors.push(...unexpected); await context.close(); console.log(`Checked ${width}px`); } if (!process.env.MOBILE_INTERACTIONS_ONLY && !process.env.MOBILE_SECTIONS) await checkStates(); } finally { await browser.close(); const failures = results.filter((r) => r.pageOverflow > 2 || r.overflows.length); await writeFile(resolve(output, "report.json"), JSON.stringify({ results, errors, failures }, null, 2)); console.log(JSON.stringify({ checks: results.length, failures: failures.length, errors })); } assert.equal(errors.length, 0, "Browser errors or missing fixtures"); assert.equal(results.filter((r) => r.pageOverflow > 2 || r.overflows.length).length, 0, "Customer layouts must fit the viewport");