文件
hyb-oa/client/tests/e2e/r6-mobile-unified-design-rollout.spec.ts
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

643 行
24 KiB
TypeScript

import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(
ROOT_DIR,
"output/playwright",
process.env.MOBILE_VISUAL_OUTPUT_DIR?.trim() || "r6-mobile-unified-design-rollout",
);
const IOS_WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const ANDROID_WECOM_UA =
"Mozilla/5.0 (Linux; Android 14; Redmi K60 Build/UKQ1.230804.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const MOBILE_BROWSER_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1";
const VIEWPORT_CASES = [
{ key: "w320-ios-wecom", label: "320 iOS 企业微信", viewport: { width: 320, height: 568 }, userAgent: IOS_WECOM_UA },
{ key: "w360-android-wecom", label: "360 Android 企业微信", viewport: { width: 360, height: 740 }, userAgent: ANDROID_WECOM_UA },
{ key: "w375-browser", label: "375 普通移动浏览器", viewport: { width: 375, height: 667 }, userAgent: MOBILE_BROWSER_UA },
{ key: "w390-ios-wecom", label: "390 iOS 企业微信", viewport: { width: 390, height: 844 }, userAgent: IOS_WECOM_UA },
{ key: "w393-android-wecom", label: "393 Android 企业微信", viewport: { width: 393, height: 873 }, userAgent: ANDROID_WECOM_UA },
{ key: "w414-ios-wecom", label: "414 iOS 企业微信", viewport: { width: 414, height: 896 }, userAgent: IOS_WECOM_UA },
{ key: "w430-browser", label: "430 普通移动浏览器", viewport: { width: 430, height: 932 }, userAgent: MOBILE_BROWSER_UA },
] as const;
const MOBILE_PAGES = [
{ key: "home", path: "/mobile", marker: "mobile-action-grid", deep: false },
{ key: "approvals", path: "/mobile/approvals", marker: "mobile-approvals-list", deep: false },
{ key: "reimbursements", path: "/mobile/reimbursements", marker: "mobile-reimbursements-list", deep: false },
{ key: "reimbursement-detail", path: "/mobile/reimbursements/mud6-reimbursement", marker: "reimbursement-mobile-workspace", deep: true },
{ key: "purchase-requests", path: "/mobile/purchase-requests", marker: "mobile-purchase-requests-list", deep: false },
{ key: "me", path: "/mobile/me", marker: "mobile-more-links", deep: false },
{ key: "profile", path: "/mobile/profile", marker: "mobile-profile-page", deep: false },
{ key: "notifications", path: "/mobile/notifications", marker: "mobile-notifications-page", deep: false },
{ key: "capture", path: "/mobile/capture", marker: "mobile-capture-file", deep: false },
{ key: "leave", path: "/mobile/leave", marker: "mobile-leave-records", deep: false },
{ key: "asset-scan", path: "/mobile/assets/scan", marker: "barcode-scanner-panel", deep: false },
] as const;
const employee = {
id: "mud6-employee",
employeeNo: "SMOKE002",
name: "OA试运行测试01",
phone: "13800000000",
email: "[email protected]",
departmentId: "mud6-dept",
directManagerId: "mud6-manager",
status: "active",
notificationPushEnabled: true,
roleCodes: ["manager", "finance_mgr", "employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 80,
},
wecomBinding: {
externalUserId: "oa_smoke_01",
createdAt: "2026-05-11T08:00:00.000Z",
},
};
function envelope(data: unknown) {
return { requestId: "mock-r6-mobile-unified", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function screenshotPath(fileName: string) {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
return path.join(SCREENSHOT_DIR, `${fileName}.png`);
}
async function installStableVisualCapture(page: Page) {
await page.addInitScript(() => {
const style = document.createElement("style");
style.setAttribute("data-testid", "mobile-visual-stability-style");
style.textContent = `
*, *::before, *::after {
animation-delay: 0ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-delay: 0ms !important;
transition-duration: 0ms !important;
}
`;
document.documentElement.appendChild(style);
});
}
async function waitForStableMobileVisual(page: Page) {
await page.locator(".hyb-mobile-shell").waitFor({ state: "visible", timeout: 10_000 });
await page.evaluate(async () => {
await document.fonts?.ready;
});
await page.waitForFunction(() => {
const shell = document.querySelector(".hyb-mobile-shell");
if (!shell) {
return false;
}
const style = window.getComputedStyle(shell);
const rect = shell.getBoundingClientRect();
return style.opacity === "1" && rect.width > 0 && rect.height > 0;
});
await page.waitForTimeout(100);
}
function reimbursementListItem() {
return {
id: "mud6-reimbursement",
formNumber: "BX20260511007",
status: "draft",
reimbursementDate: "2026-05-11",
totalAmount: "168.30",
description: "MUD6 移动统一设计验证",
payment: {},
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
project: null,
pendingWorkflow: null,
createdAt: "2026-05-11T08:00:00.000Z",
updatedAt: "2026-05-11T08:00:00.000Z",
};
}
async function mockApis(page: Page) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathName = url.pathname;
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({ accessToken: "mock-mud6-token", expiresIn: 900 })),
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
entryEnabled: true,
oauthEnabled: true,
corpId: "ww-mud6",
agentId: "1000002",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
bindEnabled: true,
defaultReturnTo: "/mobile",
status: "ready",
statusReasons: [],
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, employee);
return;
}
if (pathName === `/api/employees/${employee.id}/profile`) {
await fulfillJson(route, {
employee: {
...employee,
title: "测试员工",
avatarUrl: null,
department: { id: "mud6-dept", name: "试运行部" },
directManager: { id: "mud6-manager", name: "OA试运行测试02" },
},
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
assignedAssets: [],
organization: { reportingChain: [], directReports: [] },
});
return;
}
if (pathName === "/api/attendance/me/today") {
await fulfillJson(route, { workDate: "2026-05-11", needClockIn: false, exemptionReason: null, item: null });
return;
}
if (pathName === "/api/notifications" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
unreadOnly: false,
unreadCount: 1,
items: [
{
id: "mud6-notification",
type: "workflow.task_assigned",
title: "报销待审批",
body: "MUD6 移动统一设计待办",
targetType: "workflow_task",
targetId: "mud6-task",
readAt: null,
createdAt: "2026-05-11T08:00:00.000Z",
},
],
});
return;
}
if (pathName === "/api/workflow/tasks/summary" && request.method() === "GET") {
await fulfillJson(route, {
scope: "mine",
pendingTotal: 1,
overdueTotal: 0,
earliestPendingAt: "2026-05-11T08:00:00.000Z",
averagePendingAgeHours: 1,
byEntityType: [{ key: "reimbursement", pendingTotal: 1, overdueTotal: 0 }],
byStep: [{ key: "主管审批", pendingTotal: 1, overdueTotal: 0 }],
byAssigneeRole: [],
});
return;
}
if (pathName === "/api/workflow/tasks" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 100,
total: 1,
items: [
{
id: "mud6-task",
instanceId: "mud6-instance",
stepId: "mud6-step",
stepName: "主管审批",
assigneeId: employee.id,
assigneeRole: null,
status: "pending",
outcome: null,
comments: null,
createdAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
workflowInstance: {
id: "mud6-instance",
entityType: "reimbursement",
entityId: "mud6-reimbursement",
currentStatus: "reviewing",
startedAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
businessSummary: {
reference: "BX20260511007",
title: "报销待审批",
applicantName: employee.name,
amount: "168.30",
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
},
],
});
return;
}
if (pathName === "/api/workflow/tasks/mud6-task" && request.method() === "GET") {
await fulfillJson(route, {
task: {
id: "mud6-task",
instanceId: "mud6-instance",
stepId: "mud6-step",
stepName: "主管审批",
assigneeId: employee.id,
assigneeRole: null,
status: "pending",
outcome: null,
comment: null,
createdAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
workflowInstance: {
id: "mud6-instance",
entityType: "reimbursement",
currentStatus: "reviewing",
currentStepId: "mud6-step",
contextData: { standardViolations: [] },
startedAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
reimbursement: reimbursementListItem(),
leaveRequest: null,
purchaseRequest: null,
travelRequest: null,
allowedActions: ["approve", "reject", "return"],
history: [],
});
return;
}
if (pathName === "/api/workflow/approvers" && request.method() === "GET") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/reimbursements" && request.method() === "GET") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, scope: "own", items: [reimbursementListItem()] });
return;
}
if (pathName === "/api/purchase-requests" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
mine: true,
items: [
{
id: "mud6-purchase",
requestNo: "CG20260511001",
applicantId: employee.id,
submitterId: employee.id,
departmentId: "mud6-dept",
legalEntityId: null,
costCenterId: null,
supplierId: null,
documentCategoryId: null,
budgetPoolId: null,
title: "办公用品采购",
reason: "统一设计验证",
currencyCode: "CNY",
totalAmount: "320.00",
expectedDeliveryDate: "2026-05-20",
status: "reviewing",
createdAt: "2026-05-11T08:00:00.000Z",
updatedAt: "2026-05-11T08:00:00.000Z",
applicant: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
department: { id: "mud6-dept", name: "试运行部" },
legalEntity: null,
costCenter: null,
supplier: null,
documentCategory: null,
budgetPool: null,
},
],
});
return;
}
if (pathName === "/api/reimbursements/mud6-reimbursement") {
await fulfillJson(route, {
...reimbursementListItem(),
submitterId: employee.id,
ownerId: employee.id,
departmentId: "mud6-dept",
department: { id: "mud6-dept", name: "试运行部" },
submittedAt: null,
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
project: null,
owner: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
invoices: [],
attachments: [],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
items: [
{
id: "mud6-item",
expenseTypeId: "mud6-expense-type",
expenseDate: "2026-05-11",
amount: "168.30",
description: "统一设计验证",
expenseType: { id: "mud6-expense-type", code: "office", name: "办公费", category: "office", enabled: true },
},
],
});
return;
}
if (pathName === "/api/expense-types") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
items: [{ id: "mud6-expense-type", code: "office", name: "办公费", category: "office", enabled: true }],
});
return;
}
if (pathName === "/api/leave-types") {
await fulfillJson(route, {
items: [
{
id: "mud6-leave-type",
code: "annual",
name: "年假",
description: null,
enabled: true,
requiresAttachment: false,
attachmentMimeTypes: [],
sortOrder: 1,
policy: { id: "mud6-leave-policy", annualQuotaDays: 5, enabled: true, description: null },
createdAt: null,
updatedAt: null,
},
],
});
return;
}
if (pathName === "/api/leave-balances") {
await fulfillJson(route, {
employeeId: employee.id,
items: [
{
leaveType: { id: "mud6-leave-type", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [] },
policy: { id: "mud6-leave-policy", annualQuotaDays: 5, enabled: true, description: null },
granted: 5,
adjusted: 0,
reserved: 0,
used: 1,
available: 4,
periodStart: "2026-01-01",
periodEnd: "2026-12-31",
},
],
});
return;
}
if (pathName === "/api/leave-requests") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
scope: "own",
items: [
{
id: "mud6-leave",
ownerId: employee.id,
departmentId: "mud6-dept",
status: "draft",
startDate: "2026-05-12",
endDate: "2026-05-12",
startSession: "am",
endSession: "pm",
requestedDays: 1,
reason: "统一设计验证",
attachment: null,
leaveType: { id: "mud6-leave-type", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [] },
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
approvalHistory: [],
createdAt: "2026-05-11T08:00:00.000Z",
updatedAt: "2026-05-11T08:00:00.000Z",
},
],
});
return;
}
if (pathName === "/api/inventory-checks") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/assets") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
await fulfillJson(route, {});
});
}
async function login(page: Page) {
await page.goto("/login");
const accountLogin = page.getByRole("button", { name: "使用账号密码登录" });
if (await accountLogin.isVisible().catch(() => false)) {
await accountLogin.click();
}
await page.getByTestId("login-identifier").fill(employee.email);
await page.getByTestId("login-password").fill("Password1234");
await page.getByTestId("login-submit").click();
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
}
async function collectMetrics(page: Page) {
return page.evaluate(() => {
const viewportWidth = window.innerWidth;
const visibleElements = (selector: string) =>
Array.from(document.querySelectorAll(selector)).filter((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
});
const bottomNav = document.querySelector("[data-testid='mobile-bottom-nav']");
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
const rect = element?.getBoundingClientRect();
const centerX = rect ? rect.left + rect.width / 2 : 0;
const centerY = rect ? rect.top + rect.height / 2 : 0;
const hit = rect ? document.elementFromPoint(centerX, centerY) : null;
return {
key,
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
width: rect?.width ?? 0,
height: rect?.height ?? 0,
hitWithin: Boolean(element && hit instanceof Element && hit.closest(`[data-testid='mobile-nav-${key}']`) === element),
};
});
const desktopCardMainElements = visibleElements(".hyb-mobile-shell .card-std:not(.section-panel)").map((element) => ({
tag: element.tagName.toLowerCase(),
className: element.getAttribute("class") ?? "",
testId: element.getAttribute("data-testid") ?? "",
text: (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80),
}));
const actionSlotGaps = visibleElements(".mobile-action-link").map((element) => {
const icon = element.querySelector(".mobile-action-icon");
const copy = element.querySelector(".mobile-action-copy");
const iconRect = icon?.getBoundingClientRect();
const copyRect = copy?.getBoundingClientRect();
return iconRect && copyRect ? copyRect.left - iconRect.right : null;
}).filter((item): item is number => typeof item === "number");
const mobileSectionHasDecorativeTopLine = visibleElements(".hyb-mobile-shell .section-panel").some((element) => {
const before = window.getComputedStyle(element, "::before");
const opacity = Number.parseFloat(before.opacity || "1");
const height = Number.parseFloat(before.height || "0");
return before.display !== "none" && before.visibility !== "hidden" && opacity > 0.01 && height > 0;
});
const stickyButtons = [
document.querySelector("[data-testid='reimbursement-submit']"),
].filter(Boolean).map((element) => element!.getBoundingClientRect());
return {
viewportWidth,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
shellScoped: Boolean(document.querySelector(".hyb-mobile-shell")),
desktopSidebarCount: visibleElements("aside, [data-testid='sidebar']").length,
tableCount: visibleElements("table").length,
desktopCardMainCount: desktopCardMainElements.length,
desktopCardMainElements,
mobileCardCount: visibleElements(".mobile-card, .section-panel").length,
mobileListCardCount: visibleElements(".mobile-list-card").length,
hasDecorativeTopLine: mobileSectionHasDecorativeTopLine,
bottomNavVisible: Boolean(bottomNav?.getBoundingClientRect().height),
navItems,
actionGapMin: actionSlotGaps.length ? Math.min(...actionSlotGaps) : null,
actionGapMax: actionSlotGaps.length ? Math.max(...actionSlotGaps) : null,
stickyButtonsSameRow:
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
};
});
}
async function expectUnifiedMobilePage(page: Page, label: string, screenshotName: string, options: { deep: boolean }) {
await waitForStableMobileVisual(page);
await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true });
const metrics = await collectMetrics(page);
expect(metrics.shellScoped, `${label} mobile shell scope`).toBe(true);
expect(metrics.scrollWidth, `${label} no horizontal overflow`).toBeLessThanOrEqual(metrics.viewportWidth + 2);
expect(metrics.desktopSidebarCount, `${label} no desktop sidebar`).toBe(0);
expect(metrics.tableCount, `${label} no visible desktop table`).toBe(0);
expect(
metrics.desktopCardMainCount,
`${label} no desktop card main container ${JSON.stringify(metrics.desktopCardMainElements)}`,
).toBe(0);
expect(metrics.mobileCardCount, `${label} has mobile card system`).toBeGreaterThan(0);
expect(metrics.hasDecorativeTopLine, `${label} mobile card top line hidden`).toBe(false);
if (metrics.actionGapMin !== null) {
const minSlotGap = metrics.viewportWidth >= 390 ? 14 : 12;
expect(metrics.actionGapMin, `${label} action slot gap min`).toBeGreaterThanOrEqual(minSlotGap);
expect(metrics.actionGapMax ?? 0, `${label} action slot gap max`).toBeLessThanOrEqual(18);
}
if (options.deep) {
expect(metrics.bottomNavVisible, `${label} deep page hides bottom nav`).toBe(false);
expect(metrics.stickyButtonsSameRow, `${label} sticky buttons same row`).toBe(true);
expect(metrics.stickyButtonMinHeight, `${label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
} else {
expect(metrics.bottomNavVisible, `${label} bottom nav visible`).toBe(true);
for (const item of metrics.navItems) {
expect(item.visible, `${label} ${item.key} nav visible`).toBe(true);
expect(item.width, `${label} ${item.key} nav width`).toBeGreaterThanOrEqual(44);
expect(item.height, `${label} ${item.key} nav height`).toBeGreaterThanOrEqual(44);
expect(item.hitWithin, `${label} ${item.key} nav hit`).toBe(true);
}
}
}
test.describe("MUD6 专用移动页面统一设计", () => {
for (const device of VIEWPORT_CASES) {
test.describe(device.label, () => {
test.use({
viewport: device.viewport,
isMobile: true,
hasTouch: true,
userAgent: device.userAgent,
locale: "zh-CN",
serviceWorkers: "block",
});
test("所有专用 /mobile 页面复用统一槽位、卡片和底部导航体系", async ({ page }) => {
await installStableVisualCapture(page);
await mockApis(page);
await login(page);
for (const mobilePage of MOBILE_PAGES) {
await page.goto(mobilePage.path);
await expect(page.getByTestId(mobilePage.marker)).toBeVisible();
if (mobilePage.key === "leave") {
await expect(page.getByTestId("mobile-leave-form")).toHaveCount(0);
await expect(page.getByText("企业微信主办", { exact: true })).toBeVisible();
}
if (mobilePage.key === "approvals") {
await expect(page.getByText("批量")).toHaveCount(0);
}
await expectUnifiedMobilePage(page, `${device.label} ${mobilePage.key}`, `${device.key}-${mobilePage.key}`, {
deep: mobilePage.deep,
});
}
});
});
}
});