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: "mobile-reimbursement-detail", 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?view=form", marker: "mobile-leave-form", 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: "smoke-employee@hyinda.com", 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`); } 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='mobile-reimbursement-sticky-invoice-add']"), 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 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 mockApis(page); await login(page); for (const mobilePage of MOBILE_PAGES) { await page.goto(mobilePage.path); await expect(page.getByTestId(mobilePage.marker)).toBeVisible(); await expectUnifiedMobilePage(page, `${device.label} ${mobilePage.key}`, `${device.key}-${mobilePage.key}`, { deep: mobilePage.deep, }); } }); }); } });