import { devices, expect, test, type Page, type Route, type TestInfo } from "@playwright/test"; function browserAgnosticDevice(name: keyof typeof devices) { const device = devices[name]; return { viewport: device.viewport, userAgent: device.userAgent, deviceScaleFactor: device.deviceScaleFactor, isMobile: device.isMobile, hasTouch: device.hasTouch, }; } function envelope(data: unknown) { return { requestId: "mock-r89", data, }; } async function fulfillJson(route: Route, data: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { await testInfo.attach(name, { body: await page.screenshot({ fullPage: true }), contentType: "image/png", }); } const mockMe = { id: "employee-mock-1", employeeNo: "M001", name: "Manager", phone: "13800000000", email: "manager@hyinda.com", departmentId: "dept-1", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["manager"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50, }, wecomBinding: null, }; const pendingTask = { id: "task-r89-1", instanceId: "instance-r89-1", stepId: "manager_approve", stepName: "经理审批", assigneeId: "employee-mock-1", assigneeRole: "manager", status: "pending", outcome: null, createdAt: "2026-05-05T09:00:00.000Z", completedAt: null, workflowInstance: { entityType: "reimbursement", entityId: "reimbursement-r89-1", currentStatus: "submitted", }, allowedActions: ["approve", "reject", "return"], }; const pendingTaskDetail = { task: { id: pendingTask.id, instanceId: pendingTask.instanceId, stepId: pendingTask.stepId, stepName: pendingTask.stepName, assigneeId: pendingTask.assigneeId, assigneeRole: pendingTask.assigneeRole, status: "pending", outcome: null, comment: null, createdAt: pendingTask.createdAt, completedAt: null, }, workflowInstance: { id: pendingTask.instanceId, entityType: "reimbursement", currentStatus: "submitted", currentStepId: pendingTask.stepId, contextData: { standardViolations: [] }, startedAt: "2026-05-05T08:50:00.000Z", completedAt: null, }, reimbursement: { id: "reimbursement-r89-1", formNumber: "BX-R89-001", status: "submitted", reimbursementDate: "2026-05-05", totalAmount: "1683.00", description: "R89 移动审批验证", owner: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" }, submitter: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" }, }, leaveRequest: null, purchaseRequest: null, travelRequest: null, allowedActions: ["approve", "reject", "return"], history: [], }; function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") { return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null }; } async function mockR89Apis(page: Page) { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname; if (path === "/api/auth/wecom/login") { await route.fulfill({ status: 200, contentType: "text/plain", body: "mock wecom login entry", }); return; } if (path === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (path === "/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=%2Fportal", bindEnabled: true, defaultReturnTo: "/portal", status: "ready", statusReasons: [], }); return; } if (path === "/api/auth/login" || path === "/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-r89-access-token", expiresIn: 900, mustChangePassword: false, })), }); return; } if (path === "/api/auth/logout") { await route.fulfill({ status: 200, contentType: "application/json", headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" }, body: JSON.stringify(envelope({})), }); return; } if (path === "/api/me") { await fulfillJson(route, mockMe); return; } if (path === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "desktop", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null, }, sections: { main: [ navigationItem("portal", "/portal", "Portal", "home_app_logo"), navigationItem("team", "/team", "团队", "groups"), navigationItem("leave", "/leave-requests", "请假", "event_available"), navigationItem("expense", "/reimbursements", "报销", "request_quote"), navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"), navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"), ], admin: [], backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")], }, recommendedRolePresets: [], }); return; } if (path === "/api/presence/me") { await fulfillJson(route, { date: "2026-05-05", item: { id: "presence-r89", employeeId: mockMe.id, date: "2026-05-05", status: "office", timeRange: "full_day", note: null, source: "manual", updatedAt: "2026-05-05T08:30:00.000Z", }, }); return; } if (path === "/api/attendance/me/today") { await fulfillJson(route, { workDate: "2026-05-05", needClockIn: false, exemptionReason: "policy_disabled", item: null, }); return; } if (path === "/api/portal/team-status") { await fulfillJson(route, [ { employeeId: mockMe.id, name: mockMe.name, avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-05-05T00:30:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null, }, ]); return; } if (path === "/api/notifications") { const unreadOnly = url.searchParams.get("unreadOnly") === "1"; await fulfillJson(route, { page: 1, pageSize: Number(url.searchParams.get("pageSize") ?? 5), total: unreadOnly ? 1 : 2, unreadOnly, unreadCount: 1, items: [ { id: "notification-r89-1", type: "workflow", title: "有新的审批待办", body: "请处理 R89 测试单据", targetType: "workflow_task", targetId: pendingTask.id, readAt: null, createdAt: "2026-05-05T09:10:00.000Z", }, ], }); return; } if (path === "/api/workflow/tasks") { const status = url.searchParams.get("status") ?? "pending"; await fulfillJson(route, { page: 1, pageSize: Number(url.searchParams.get("pageSize") ?? 100), total: status === "pending" ? 1 : 0, items: status === "pending" ? [pendingTask] : [], }); return; } if (/^\/api\/workflow\/tasks\/[^/]+\/(approve|reject|return|transfer)$/.test(path)) { await fulfillJson(route, { ok: true }); return; } if (/^\/api\/workflow\/tasks\/[^/]+$/.test(path)) { await fulfillJson(route, pendingTaskDetail); return; } if (path === "/api/workflow/approvers") { await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] }); return; } if (path === `/api/employees/${mockMe.id}/profile`) { await fulfillJson(route, { employee: { id: mockMe.id, employeeNo: mockMe.employeeNo, name: mockMe.name, phone: mockMe.phone, email: mockMe.email, status: "active", employmentStatus: "active", departmentId: "dept-1", title: "负责人", directManagerId: null, hireDate: "2026-01-01", probationEndDate: null, lastWorkingDate: null, workLocation: "北京", emergencyContactName: null, emergencyContactPhone: null, notes: null, avatarUrl: null, archivedAt: null, roleCodes: mockMe.roleCodes, department: { id: "dept-1", name: "研发" }, directManager: null, }, lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } }, assignedAssets: [], organization: { reportingChain: [], directReports: [] }, }); return; } if (path === "/api/invoice-pool/upload") { await fulfillJson(route, { invoicePoolId: "invoice-r89-1", status: "pending_ocr", fileSha256: "mock-sha256", }); return; } await fulfillJson(route, {}); }); } async function loginByPassword(page: Page) { await page.goto("/login"); await page.getByTestId("login-identifier").fill("manager@hyinda.com"); await page.getByTestId("login-password").fill("ManagerPassword1234"); await page.getByTestId("login-submit").click(); } test.describe("R89 企业微信移动入口", () => { test.use({ ...browserAgnosticDevice("iPhone 13"), userAgent: "Mozilla/5.0 iPhone wxwork/4.1.20 MicroMessenger/7.0.1", }); test("企业微信移动场景默认生成移动工作台入口", async ({ page }, testInfo) => { await mockR89Apis(page); await page.goto("/login"); await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible(); await expect(page.getByTestId("wecom-entry-primary")).toBeVisible(); await expect(page.getByTestId("login-password-primary-panel")).toHaveCount(0); await attachScreenshot(page, testInfo, "r96-wecom-login.png"); await page.getByTestId("wecom-entry-primary").click(); await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=%2Fmobile/); }); }); test.describe("R89 移动底部导航与核心页面", () => { test.use(browserAgnosticDevice("iPhone 13")); test("密码登录后可完成移动首页、审批、票据和我的页关键路径", async ({ page }, testInfo) => { await mockR89Apis(page); await loginByPassword(page); await expect(page).toHaveURL(/\/mobile$/); await expect(page.getByTestId("mobile-today-focus")).toBeVisible(); await expect(page.getByTestId("topbar-profile-trigger")).toBeHidden(); await attachScreenshot(page, testInfo, "r96-mobile-today.png"); await expect(page.getByTestId("mobile-nav-home")).toBeVisible(); await expect(page.getByTestId("mobile-nav-approvals")).toBeVisible(); await expect(page.getByTestId("mobile-nav-capture")).toBeVisible(); await expect(page.getByTestId("mobile-nav-me")).toBeVisible(); await page.getByTestId("mobile-nav-approvals").click(); await expect(page).toHaveURL(/\/mobile\/approvals/); await expect(page.getByTestId("mobile-approvals-list")).toBeVisible(); await page.getByTestId(`mobile-approvals-task-${pendingTask.id}`).click(); await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible(); await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible(); await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible(); await attachScreenshot(page, testInfo, "r96-mobile-approvals.png"); await page.getByTestId("mobile-nav-capture").click(); await expect(page).toHaveURL(/\/mobile\/capture/); await expect(page.getByText("拍照或选择文件", { exact: true })).toBeVisible(); await page.getByTestId("mobile-capture-file").setInputFiles({ name: "r89-invoice.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n% R89"), }); await expect(page.getByTestId("mobile-capture-upload")).toBeEnabled(); await attachScreenshot(page, testInfo, "r96-mobile-capture.png"); await page.getByTestId("mobile-nav-me").click(); await expect(page).toHaveURL(/\/mobile\/me/); await expect(page.getByTestId("mobile-more-links")).toBeVisible(); await expect(page.getByRole("link", { name: /个人设置/ })).toBeVisible(); await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible(); }); }); test.describe("R89 小屏桌面菜单兜底", () => { test.use(browserAgnosticDevice("Pixel 5")); test("非移动路由的小屏菜单可以打开并关闭", async ({ page }) => { await mockR89Apis(page); await loginByPassword(page); await page.getByTestId("mobile-nav-me").click(); await page.getByRole("link", { name: /通知中心/ }).click(); await expect(page).toHaveURL(/\/notifications/); await page.getByRole("button", { name: "打开菜单" }).click(); await expect(page.getByRole("button", { name: "关闭菜单" })).toBeVisible(); await expect(page.getByRole("link", { name: "Portal" })).toBeVisible(); await page.getByRole("button", { name: "关闭菜单" }).click(); await expect(page.getByRole("button", { name: "关闭菜单" })).toBeHidden(); }); });