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-iphone11-wecom-mobile-layout-hotfix"); const DEVICE_CASES = [ { key: "iphone11-ios14", label: "iPhone 11 iOS14", viewport: { width: 414, height: 896 }, userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN", }, { key: "redmi-k60", label: "Redmi K60", viewport: { width: 393, height: 873 }, userAgent: "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", }, ] as const; const EXPECTED_HOME_ACTIONS = [ { key: "new-reimbursement", label: "新建报销" }, { key: "capture", label: "上传票据" }, { key: "leave", label: "请假记录" }, { key: "purchase", label: "采购" }, ] as const; const employee = { id: "11111111-1111-4111-8111-111111111111", employeeNo: "HYD000006", name: "王顺利", phone: "13800000000", email: "wangshunli@hyinda.com", departmentId: "dept-1", directManagerId: "manager-1", status: "active", notificationPushEnabled: true, roleCodes: ["employee", "manager"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50, }, wecomBinding: null, }; function envelope(data: unknown) { return { requestId: "mock-r5-mobile-ux", 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 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-r5-token", expiresIn: 900 })), }); 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: "dept-1", name: "管理部" }, directManager: { id: "manager-1", name: "马建勇" }, }, lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } }, assignedAssets: [], organization: { reportingChain: [], directReports: [] }, }); return; } if (pathName === "/api/reimbursements" && request.method() === "GET") { await fulfillJson(route, { page: 1, pageSize: 20, total: 1, scope: "own", items: [ { id: "r5-reimbursement-1", formNumber: null, status: "draft", reimbursementDate: "2026-05-10", totalAmount: "168.30", description: "R5 移动交互验收", 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-10T08:00:00.000Z", updatedAt: "2026-05-10T08:00:00.000Z", }, ], }); return; } if (pathName === "/api/reimbursements/r5-reimbursement-1") { await fulfillJson(route, { id: "r5-reimbursement-1", formNumber: null, submitterId: employee.id, ownerId: employee.id, departmentId: "dept-1", department: { id: "dept-1", name: "管理部" }, status: "draft", createdAt: "2026-05-10T08:00:00.000Z", submittedAt: null, reimbursementDate: "2026-05-10", totalAmount: "168.30", description: "R5 移动交互验收", 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: "item-1", expenseTypeId: "expense-type-1", expenseDate: "2026-05-10", amount: "168.30", description: "正式运行口径验证", expenseType: { id: "expense-type-1", code: "office", name: "办公费", category: "office", enabled: true }, }, ], }); return; } if (pathName === "/api/workflow/tasks/summary" && request.method() === "GET") { await fulfillJson(route, { scope: "mine", pendingTotal: 0, overdueTotal: 0, earliestPendingAt: null, averagePendingAgeHours: 0, byEntityType: [], byStep: [], byAssigneeRole: [], }); return; } if (pathName === "/api/workflow/tasks" && request.method() === "GET") { await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [], }); return; } if (pathName === "/api/workflow/approvers" && request.method() === "GET") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [], }); return; } if (pathName === "/api/expense-types") { await fulfillJson(route, { page: 1, pageSize: 20, total: 1, items: [{ id: "expense-type-1", code: "office", name: "办公费", category: "office", enabled: true }], }); 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: "wx-test-corp", agentId: "1000002", loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile", bindEnabled: true, defaultReturnTo: "/mobile", status: "ready", statusReasons: [], }); 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 collectMobileMetrics(page: Page) { return page.evaluate(() => { const bottomNav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav"); const bottomNavRect = bottomNav?.getBoundingClientRect(); const bottomNavStyle = bottomNav ? window.getComputedStyle(bottomNav) : null; 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; const hitOwner = hit instanceof Element ? hit.closest(`[data-testid='mobile-nav-${key}']`) : null; return { key, visible: Boolean(rect && rect.width > 0 && rect.height > 0), width: rect?.width ?? 0, height: rect?.height ?? 0, centerX, centerY, hitWithin: Boolean(element && hitOwner === element), hitTarget: hit instanceof HTMLElement ? hit.dataset.testid ?? hit.getAttribute("aria-label") ?? hit.tagName.toLowerCase() : null, }; }); const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']"); const stickyRect = stickyActions?.getBoundingClientRect(); const stickyButtons = [ document.querySelector("[data-testid='reimbursement-submit']"), ].filter(Boolean).map((element) => element!.getBoundingClientRect()); const actionItems = Array.from( document.querySelectorAll("[data-testid='mobile-action-grid'] > a[data-testid^='mobile-action-']"), ).map((element) => { const testId = element.getAttribute("data-testid") ?? ""; const key = testId.replace(/^mobile-action-/, ""); const label = element.querySelector(`[data-testid='mobile-action-${key}-label']`); const icon = element.querySelector(`[data-testid='mobile-action-${key}-icon']`); const rect = element?.getBoundingClientRect(); const labelRect = label?.getBoundingClientRect(); const iconRect = icon?.getBoundingClientRect(); const style = label ? window.getComputedStyle(label) : null; const overlaps = Boolean( labelRect && iconRect && !(labelRect.right <= iconRect.left || labelRect.left >= iconRect.right || labelRect.bottom <= iconRect.top || labelRect.top >= iconRect.bottom), ); return { key, visible: Boolean(rect && rect.width > 0 && rect.height > 0), width: rect?.width ?? 0, height: rect?.height ?? 0, text: label?.textContent?.trim() ?? "", labelWidth: labelRect?.width ?? 0, labelHeight: labelRect?.height ?? 0, labelLineHeight: style ? Number.parseFloat(style.lineHeight) || 18 : 18, labelWhiteSpace: style?.whiteSpace ?? "", iconWidth: iconRect?.width ?? 0, iconHeight: iconRect?.height ?? 0, iconSvgTagName: icon?.querySelector("svg")?.tagName.toLowerCase() ?? "", overlaps, labelWithin: Boolean(rect && labelRect) && labelRect!.left >= rect!.left - 1 && labelRect!.right <= rect!.right + 1 && labelRect!.top >= rect!.top - 1 && labelRect!.bottom <= rect!.bottom + 1, iconWithin: Boolean(rect && iconRect) && iconRect!.left >= rect!.left - 1 && iconRect!.right <= rect!.right + 1 && iconRect!.top >= rect!.top - 1 && iconRect!.bottom <= rect!.bottom + 1, }; }).filter((item) => item.visible); return { width: window.innerWidth, height: window.innerHeight, scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length, bottomNavVisible: Boolean(bottomNavRect && bottomNavRect.height > 0), bottomNavFixed: bottomNavStyle?.position === "fixed", bottomNavBottomGap: bottomNavRect ? window.innerHeight - bottomNavRect.bottom : null, navItems, stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2), stickyBottomGap: stickyRect ? window.innerHeight - stickyRect.bottom : 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, stickyButtonMinWidth: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.width)) : 0, actionItems, text: document.body?.innerText ?? "", }; }); } test.describe("R5 真机反馈移动交互", () => { for (const device of DEVICE_CASES) { test.describe(device.label, () => { test.use({ viewport: device.viewport, isMobile: true, hasTouch: true, userAgent: device.userAgent, locale: "zh-CN", serviceWorkers: "block", }); test("中文头像首字、底部 Tab 固定和详情深层页单一主按钮符合企业微信体验", async ({ page }) => { await mockApis(page); await login(page); await page.goto("/mobile/me"); await expect(page.getByText("王", { exact: true })).toBeVisible(); await page.screenshot({ path: screenshotPath(`${device.key}-mobile-me-avatar`), fullPage: true }); await page.goto("/mobile"); await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible(); await expect(page.getByTestId("mobile-nav-home")).toBeVisible(); await page.screenshot({ path: screenshotPath(`${device.key}-mobile-home-bottom-nav`), fullPage: true }); const homeMetrics = await collectMobileMetrics(page); expect(homeMetrics.bottomNavVisible).toBe(true); expect(homeMetrics.bottomNavFixed).toBe(true); expect(homeMetrics.bottomNavBottomGap).not.toBeNull(); expect(homeMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2); expect(homeMetrics.scrollWidth).toBeLessThanOrEqual(homeMetrics.width + 2); expect(homeMetrics.actionItems.map(({ key, text }) => ({ key, label: text }))).toEqual(EXPECTED_HOME_ACTIONS); for (const action of homeMetrics.actionItems) { expect(action.height, `${device.label} ${action.text} action touch height`).toBeGreaterThanOrEqual(44); expect(action.labelWhiteSpace, `${device.label} ${action.text} action label no wrap`).toBe("nowrap"); expect(action.labelHeight, `${device.label} ${action.text} action label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2); expect(action.labelWidth, `${device.label} ${action.text} action label readable width`).toBeGreaterThanOrEqual(20); expect(action.iconWidth, `${device.label} ${action.text} action icon width`).toBeGreaterThanOrEqual(28); expect(action.iconHeight, `${device.label} ${action.text} action icon height`).toBeGreaterThanOrEqual(28); expect(action.iconSvgTagName, `${device.label} ${action.text} action uses SVG icon`).toBe("svg"); expect(action.overlaps, `${device.label} ${action.text} action label/icon overlap`).toBe(false); expect(action.labelWithin, `${device.label} ${action.text} action label within tile`).toBe(true); expect(action.iconWithin, `${device.label} ${action.text} action icon within tile`).toBe(true); } for (const item of homeMetrics.navItems) { expect(item.visible, `${device.label} ${item.key} nav item visible`).toBe(true); expect(item.width, `${device.label} ${item.key} nav item touch width`).toBeGreaterThanOrEqual(44); expect(item.height, `${device.label} ${item.key} nav item touch height`).toBeGreaterThanOrEqual(44); expect(item.hitWithin, `${device.label} ${item.key} nav item hit target: ${item.hitTarget}`).toBe(true); } await page.getByTestId("mobile-nav-approvals").click(); await expect(page).toHaveURL(/\/mobile\/approvals$/); await expect(page.getByTestId("mobile-approvals-list")).toBeVisible(); await page.screenshot({ path: screenshotPath(`${device.key}-mobile-approvals-tab-hit`) }); const approvalsMetrics = await collectMobileMetrics(page); expect(approvalsMetrics.bottomNavVisible).toBe(true); expect(approvalsMetrics.bottomNavFixed).toBe(true); expect(approvalsMetrics.scrollWidth).toBeLessThanOrEqual(approvalsMetrics.width + 2); for (const item of approvalsMetrics.navItems) { expect(item.hitWithin, `${device.label} ${item.key} approvals page hit target: ${item.hitTarget}`).toBe(true); } await page.goto("/mobile/reimbursements"); await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible(); await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible(); await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursements-tab`), fullPage: true }); const listMetrics = await collectMobileMetrics(page); expect(listMetrics.bottomNavVisible).toBe(true); expect(listMetrics.bottomNavFixed).toBe(true); expect(listMetrics.bottomNavBottomGap).not.toBeNull(); expect(listMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2); expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2); await page.goto("/mobile/reimbursements/r5-reimbursement-1"); await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible(); await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0); await expect(page.getByRole("button", { name: "返回" })).toBeVisible(); await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible(); await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toHaveCount(0); await expect(page.getByTestId("reimbursement-invoice-add")).toBeVisible(); await expect(page.getByTestId("reimbursement-submit")).toBeVisible(); await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursement-detail-sticky`), fullPage: true }); const detailMetrics = await collectMobileMetrics(page); expect(detailMetrics.bottomNavVisible).toBe(false); expect(detailMetrics.stickyActionsVisible).toBe(true); expect(detailMetrics.stickyBottomGap).not.toBeNull(); expect(detailMetrics.stickyBottomGap!).toBeLessThanOrEqual(2); expect(detailMetrics.stickyButtonsSameRow).toBe(true); expect(detailMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44); expect(detailMetrics.stickyButtonMinWidth).toBeGreaterThanOrEqual(120); expect(detailMetrics.tableCount).toBe(0); expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2); expect(detailMetrics.text).not.toContain("发票池"); expect(detailMetrics.text).not.toContain("绑定整单"); }); }); } });