Files
story/test/e2e/visual-customer-mobile.mjs
T
2026-09-12 08:57:29 +08:00

213 lines
13 KiB
JavaScript

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");