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/r5-real-device-feedback-mobile-ux"); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 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 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"], 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/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 stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']"); const stickyRect = stickyActions?.getBoundingClientRect(); return { width: window.innerWidth, 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(bottomNav && bottomNav.getBoundingClientRect().height > 0), stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2), text: document.body?.innerText ?? "", }; }); } test.describe("R5 真机反馈移动交互", () => { test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, userAgent: WECOM_UA, 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("mobile-me-avatar"), fullPage: 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("mobile-reimbursements-tab"), fullPage: true }); const listMetrics = await collectMobileMetrics(page); expect(listMetrics.bottomNavVisible).toBe(true); expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2); await page.goto("/mobile/reimbursements/r5-reimbursement-1"); await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible(); await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0); await expect(page.getByRole("link", { name: "返回" })).toBeVisible(); await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible(); await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toBeVisible(); await expect(page.getByTestId("reimbursement-submit")).toBeVisible(); await page.screenshot({ path: screenshotPath("mobile-reimbursement-detail-sticky"), fullPage: true }); const detailMetrics = await collectMobileMetrics(page); expect(detailMetrics.bottomNavVisible).toBe(false); expect(detailMetrics.stickyActionsVisible).toBe(true); expect(detailMetrics.tableCount).toBe(0); expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2); expect(detailMetrics.text).not.toContain("发票池"); expect(detailMetrics.text).not.toContain("绑定整单"); }); });