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/r6-wecom-mobile-rendering-hardening"); const IPHONE_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 VIEWPORT_CASES = [ { key: "w320-iphone-wecom", label: "320 iPhone 企业微信", viewport: { width: 320, height: 568 }, userAgent: IPHONE_WECOM_UA }, { key: "w360-android-wecom", label: "360 Android 企业微信", viewport: { width: 360, height: 740 }, userAgent: ANDROID_WECOM_UA }, { key: "w375-iphone-wecom", label: "375 iPhone 企业微信", viewport: { width: 375, height: 667 }, userAgent: IPHONE_WECOM_UA }, { key: "w390-iphone12-wecom", label: "390 iPhone12 企业微信", viewport: { width: 390, height: 844 }, userAgent: IPHONE_WECOM_UA }, { key: "w393-android-wecom", label: "393 Android 企业微信", viewport: { width: 393, height: 873 }, userAgent: ANDROID_WECOM_UA }, { key: "w414-iphone-wecom", label: "414 iPhone 企业微信", viewport: { width: 414, height: 896 }, userAgent: IPHONE_WECOM_UA }, { key: "w430-iphone-wecom", label: "430 iPhone 企业微信", viewport: { width: 430, height: 932 }, userAgent: IPHONE_WECOM_UA }, ] as const; const IPHONE_ENGINE_CASE = { key: "iphone12-wecom-w390-font-failure", label: "390 iPhone12 企业微信 字体失败", viewport: { width: 390, height: 844 }, userAgent: IPHONE_WECOM_UA, } as const; const employee = { id: "wcm6-employee", employeeNo: "SMOKE002", name: "OA试运行测试01", phone: "13800000000", email: "smoke-employee@hyinda.com", departmentId: "wcm6-dept", directManagerId: "wcm6-manager", status: "active", notificationPushEnabled: true, roleCodes: ["manager", "employee"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 60, }, wecomBinding: { externalUserId: "oa_smoke_01", createdAt: "2026-05-11T08:00:00.000Z", }, }; function envelope(data: unknown) { return { requestId: "mock-r6-wecom-rendering", 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: "wcm6-reimbursement", formNumber: "BX20260511006", status: "draft", reimbursementDate: "2026-05-11", totalAmount: "168.30", description: "WCM6 移动渲染稳定化验证", 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 blockMaterialSymbolsFont(page: Page) { await page.route((url) => /material-symbols.*\.(woff2|css)$/i.test(url.pathname), async (route) => { await route.abort(); }); } 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-wcm6-token", expiresIn: 900 })), }); return; } if (pathName === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (pathName === "/api/auth/capabilities") { await fulfillJson(route, { emailDelivery: { ready: true, statusReasons: [], }, emailLoginCode: { enabled: true, }, passwordReset: { enabled: true, }, }); return; } if (pathName === "/api/auth/wecom/config") { await fulfillJson(route, { enabled: true, entryEnabled: true, oauthEnabled: true, corpId: "ww-wcm6", 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: "wcm6-dept", name: "试运行部" }, directManager: { id: "wcm6-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: true, 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: "wcm6-notification", type: "workflow.task_assigned", title: "报销待审批", body: "WCM6 移动渲染稳定化待办", targetType: "workflow_task", targetId: "wcm6-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: "wcm6-task", instanceId: "wcm6-instance", stepId: "wcm6-step", stepName: "主管审批", assigneeId: employee.id, assigneeRole: null, status: "pending", outcome: null, comments: null, createdAt: "2026-05-11T08:00:00.000Z", completedAt: null, workflowInstance: { id: "wcm6-instance", entityType: "reimbursement", entityId: "wcm6-reimbursement", currentStatus: "reviewing", startedAt: "2026-05-11T08:00:00.000Z", completedAt: null, }, businessSummary: { reference: "BX20260511006", title: "报销待审批", applicantName: employee.name, amount: "168.30", duration: null, businessStatus: "reviewing", }, allowedActions: ["approve", "reject", "return"], }, ], }); return; } if (pathName === "/api/workflow/tasks/wcm6-task" && request.method() === "GET") { await fulfillJson(route, { task: { id: "wcm6-task", instanceId: "wcm6-instance", stepId: "wcm6-step", stepName: "主管审批", assigneeId: employee.id, assigneeRole: null, status: "pending", outcome: null, comment: null, createdAt: "2026-05-11T08:00:00.000Z", completedAt: null, }, workflowInstance: { id: "wcm6-instance", entityType: "reimbursement", currentStatus: "reviewing", currentStepId: "wcm6-step", contextData: { standardViolations: [] }, startedAt: "2026-05-11T08:00:00.000Z", completedAt: null, }, reimbursement: { id: "wcm6-reimbursement", formNumber: "BX20260511006", status: "reviewing", reimbursementDate: "2026-05-11", totalAmount: "168.30", description: "WCM6 移动渲染稳定化验证", owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name }, submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name }, }, 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/reimbursements/wcm6-reimbursement") { await fulfillJson(route, { ...reimbursementListItem(), submitterId: employee.id, ownerId: employee.id, departmentId: "wcm6-dept", department: { id: "wcm6-dept", name: "试运行部" }, submittedAt: null, projectId: null, linkedTravelRequestId: null, linkedTravelRequest: 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, workflowProgress: null, approvalHistory: [], riskFlags: [], items: [ { id: "wcm6-item", expenseTypeId: "wcm6-expense-type", expenseDate: "2026-05-11", amount: "168.30", description: "移动渲染验证", expenseType: { id: "wcm6-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: "wcm6-expense-type", code: "office", name: "办公费", category: "office", enabled: true }], }); 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(); } else { const accountLoginText = page.getByText("使用账号密码登录", { exact: true }); if (await accountLoginText.isVisible().catch(() => false)) { await accountLoginText.click(); } } await expect(page.getByTestId("login-identifier")).toBeVisible(); 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 collectRenderingMetrics(page: Page) { return page.evaluate(() => { const viewportWidth = window.innerWidth; const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => { const element = document.querySelector(`[data-testid='mobile-nav-${key}']`); const icon = element?.querySelector("svg"); const label = element?.querySelector(`[data-testid='mobile-nav-${key}-label']`); const badge = document.querySelector(`[data-testid='mobile-nav-${key}-badge']`); const rect = element?.getBoundingClientRect(); const iconRect = icon?.getBoundingClientRect(); const labelRect = label?.getBoundingClientRect(); const badgeRect = badge?.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), iconVisible: Boolean(iconRect && iconRect.width >= 16 && iconRect.height >= 16), labelVisible: Boolean(labelRect && labelRect.width >= 20 && labelRect.height > 0), badgeOverlapsLabel: Boolean(badgeRect && labelRect) && !(badgeRect!.right <= labelRect!.left || badgeRect!.left >= labelRect!.right || badgeRect!.bottom <= labelRect!.top || badgeRect!.top >= labelRect!.bottom), badgeOverlapsIcon: Boolean(badgeRect && iconRect) && !(badgeRect!.right <= iconRect!.left || badgeRect!.left >= iconRect!.right || badgeRect!.bottom <= iconRect!.top || badgeRect!.top >= iconRect!.bottom), }; }); const actionItems = Array.from(document.querySelectorAll("[data-testid^='mobile-action-'].mobile-icon-action")).map((element) => { const key = element.getAttribute("data-testid")?.replace(/^mobile-action-/, "") ?? ""; const icon = element.querySelector(`[data-testid='mobile-action-${key}-icon'] svg`); const label = element.querySelector(`[data-testid='mobile-action-${key}-label']`); const rect = element?.getBoundingClientRect(); const iconRect = icon?.getBoundingClientRect(); const labelRect = label?.getBoundingClientRect(); const labelStyle = label ? window.getComputedStyle(label) : null; return { key, visible: Boolean(rect && rect.width > 0 && rect.height > 0), width: rect?.width ?? 0, height: rect?.height ?? 0, iconVisible: Boolean(iconRect && iconRect.width >= 16 && iconRect.height >= 16), labelHeight: labelRect?.height ?? 0, labelLineHeight: labelStyle ? Number.parseFloat(labelStyle.lineHeight) || 18 : 18, labelWhiteSpace: labelStyle?.whiteSpace ?? "", overlaps: Boolean(labelRect && iconRect) && !(labelRect!.right <= iconRect!.left || labelRect!.left >= iconRect!.right || labelRect!.bottom <= iconRect!.top || labelRect!.top >= iconRect!.bottom), }; }); const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']"); const stickyButtons = [ document.querySelector("[data-testid='reimbursement-submit']"), ].filter(Boolean).map((element) => element!.getBoundingClientRect()); const mobileShell = document.querySelector(".hyb-mobile-shell"); const avatarSlotRect = document.querySelector("[data-testid='mobile-me-avatar-slot']")?.getBoundingClientRect(); const profileNameRect = document.querySelector("[data-testid='mobile-me-profile-name']")?.getBoundingClientRect(); const actionGaps = Array.from(document.querySelectorAll("[data-testid='mobile-me-action-link']")).map((element) => { const iconRect = element.querySelector(".mobile-me-action-icon")?.getBoundingClientRect(); const copyRect = element.querySelector(".mobile-me-action-copy")?.getBoundingClientRect(); const linkRect = element.getBoundingClientRect(); return { gap: iconRect && copyRect ? copyRect.left - iconRect.right : 0, iconWithin: Boolean(iconRect) && iconRect!.left >= linkRect.left - 1 && iconRect!.right <= linkRect.right + 1 && iconRect!.top >= linkRect.top - 1 && iconRect!.bottom <= linkRect.bottom + 1, copyWithin: Boolean(copyRect) && copyRect!.left >= linkRect.left - 1 && copyRect!.right <= linkRect.right + 1 && copyRect!.top >= linkRect.top - 1 && copyRect!.bottom <= linkRect.bottom + 1, }; }); const moreLinkRects = Array.from(document.querySelectorAll("[data-testid='mobile-more-links'] > *")).map((element) => element.getBoundingClientRect(), ); const mobileSectionHasDecorativeTopLine = Array.from(document.querySelectorAll(".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; }); return { viewportWidth, scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), coreMaterialIconCount: document.querySelectorAll("[data-testid='mobile-bottom-nav'] .material-symbols-outlined, [data-testid='mobile-action-grid'] .material-symbols-outlined").length, coreSvgCount: document.querySelectorAll("[data-testid='mobile-bottom-nav'] svg, [data-testid='mobile-action-grid'] svg").length, bottomNavVisible: Boolean(document.querySelector("[data-testid='mobile-bottom-nav']")?.getBoundingClientRect().height), tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length, navItems, actionItems, stickyActionsVisible: Boolean(stickyActions?.getBoundingClientRect().height), 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, mobileMe: { shellScoped: Boolean(mobileShell), profileGap: avatarSlotRect && profileNameRect ? profileNameRect.left - avatarSlotRect.right : null, actionGapMin: actionGaps.length ? Math.min(...actionGaps.map((item) => item.gap)) : null, actionGapMax: actionGaps.length ? Math.max(...actionGaps.map((item) => item.gap)) : null, actionSlotsWithin: actionGaps.every((item) => item.iconWithin && item.copyWithin), moreLinksTwoColumns: moreLinkRects.length >= 2 ? Math.abs(moreLinkRects[0].top - moreLinkRects[1].top) <= 4 && moreLinkRects[0].right <= moreLinkRects[1].left : true, hasDecorativeTopLine: mobileSectionHasDecorativeTopLine, }, }; }); } async function expectRenderingStable(page: Page, label: string, screenshotName: string) { await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true }); const metrics = await collectRenderingMetrics(page); expect(metrics.scrollWidth, `${label} no horizontal overflow`).toBeLessThanOrEqual(metrics.viewportWidth + 2); expect(metrics.tableCount, `${label} no visible desktop table`).toBe(0); expect(metrics.coreMaterialIconCount, `${label} no core material font icons`).toBe(0); if (metrics.bottomNavVisible) { expect(metrics.coreSvgCount, `${label} core svg icons`).toBeGreaterThanOrEqual(4); 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); expect(item.iconVisible, `${label} ${item.key} nav svg visible`).toBe(true); expect(item.labelVisible, `${label} ${item.key} nav label visible`).toBe(true); expect(item.badgeOverlapsLabel, `${label} ${item.key} badge not over label`).toBe(false); expect(item.badgeOverlapsIcon, `${label} ${item.key} badge not over icon`).toBe(false); } } return metrics; } async function exerciseMobileRendering(page: Page, key: string, label: string) { await mockApis(page); await login(page); await page.goto("/mobile"); await expect(page.getByTestId("mobile-action-grid")).toBeVisible(); const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`); expect(homeMetrics.actionItems.length, `${label} action count`).toBeGreaterThanOrEqual(2); const bottomTabLabels = new Set(["首页", "待我审批", "进度", "报销", "我的"]); for (const action of homeMetrics.actionItems) { expect(action.visible, `${label} ${action.key} action visible`).toBe(true); expect(bottomTabLabels.has(action.key), `${label} ${action.key} not bottom tab key`).toBe(false); expect(action.width, `${label} ${action.key} action width`).toBeGreaterThanOrEqual(44); expect(action.height, `${label} ${action.key} action height`).toBeGreaterThanOrEqual(44); expect(action.iconVisible, `${label} ${action.key} action svg visible`).toBe(true); expect(action.labelWhiteSpace, `${label} ${action.key} label nowrap`).toBe("nowrap"); expect(action.labelHeight, `${label} ${action.key} label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2); expect(action.overlaps, `${label} ${action.key} icon/label overlap`).toBe(false); } await page.getByTestId("mobile-nav-approvals").click(); await expect(page).toHaveURL(/\/mobile\/approvals(?:\?.*)?$/); await expect(page.getByTestId("mobile-approvals-list")).toBeVisible(); await expectRenderingStable(page, label, `${key}-mobile-approvals`); await page.goto("/mobile/capture"); await expect(page.getByTestId("mobile-capture-file")).toBeVisible(); await expectRenderingStable(page, label, `${key}-mobile-capture`); await page.goto("/mobile/me"); await expect(page.getByTestId("mobile-more-links")).toBeVisible(); const meMetrics = await expectRenderingStable(page, label, `${key}-mobile-me`); const minSlotGap = meMetrics.viewportWidth >= 390 ? 14 : 12; expect(meMetrics.mobileMe.shellScoped, `${label} mobile shell scope`).toBe(true); expect(meMetrics.mobileMe.profileGap, `${label} profile avatar/text gap`).not.toBeNull(); expect(meMetrics.mobileMe.profileGap ?? 0, `${label} profile avatar/text gap min`).toBeGreaterThanOrEqual(minSlotGap); expect(meMetrics.mobileMe.profileGap ?? 0, `${label} profile avatar/text gap max`).toBeLessThanOrEqual(20); expect(meMetrics.mobileMe.actionGapMin, `${label} action icon/text gap min`).not.toBeNull(); expect(meMetrics.mobileMe.actionGapMin ?? 0, `${label} action icon/text gap min`).toBeGreaterThanOrEqual(minSlotGap); expect(meMetrics.mobileMe.actionGapMax ?? 0, `${label} action icon/text gap max`).toBeLessThanOrEqual(18); expect(meMetrics.mobileMe.actionSlotsWithin, `${label} action slots within link`).toBe(true); expect(meMetrics.mobileMe.moreLinksTwoColumns, `${label} more links keep two columns`).toBe(true); expect(meMetrics.mobileMe.hasDecorativeTopLine, `${label} mobile section top line hidden`).toBe(false); await page.goto("/mobile/reimbursements/wcm6-reimbursement"); await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible(); await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0); await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible(); const detailMetrics = await expectRenderingStable(page, label, `${key}-mobile-reimbursement-detail`); expect(detailMetrics.stickyActionsVisible, `${label} sticky actions visible`).toBe(true); expect(detailMetrics.stickyButtonsSameRow, `${label} sticky buttons same row`).toBe(true); expect(Math.round(detailMetrics.stickyButtonMinHeight), `${label} sticky buttons touch height`).toBeGreaterThanOrEqual(44); } test.describe("WCM6 企业微信多手机移动渲染稳定化", () => { 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("核心移动入口使用 SVG 图标且跨视口不挤压", async ({ page }) => { await exerciseMobileRendering(page, device.key, device.label); }); }); } test.describe(IPHONE_ENGINE_CASE.label, () => { test.use({ viewport: IPHONE_ENGINE_CASE.viewport, isMobile: true, hasTouch: true, userAgent: IPHONE_ENGINE_CASE.userAgent, locale: "zh-CN", serviceWorkers: "block", }); test("Material Symbols 字体失败时核心移动入口仍稳定", async ({ page }) => { await blockMaterialSymbolsFont(page); await exerciseMobileRendering(page, IPHONE_ENGINE_CASE.key, IPHONE_ENGINE_CASE.label); }); }); });