import { devices, expect, test } from "@playwright/test"; import { apiGetData, apiPostData, createAuthenticatedApiClient, EMPLOYEE, MANAGER, type E2EUser } from "./helpers/auth"; import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "./helpers/workflow"; type LeaveTypeListResponse = { items: Array<{ id: string; requiresAttachment: boolean }>; }; type WorkflowSubmitResponse = { workflowInstanceId: string; }; function formatDate(offsetDays: number) { const value = new Date(); value.setUTCDate(value.getUTCDate() + offsetDays); return value.toISOString().slice(0, 10); } async function createSubmittedLeaveViaApi(input: { reasonPrefix: string }) { const client = await createAuthenticatedApiClient(EMPLOYEE); const leaveTypes = await apiGetData(client.api, "/api/leave-types?enabled=true"); const leaveType = leaveTypes.items.find((item) => !item.requiresAttachment) ?? leaveTypes.items[0]; if (!leaveType) { throw new Error("缺少可用于移动审批离线测试的请假类型"); } const offset = 3600 + (Date.now() % 1200); const startDate = formatDate(offset); const created = await apiPostData<{ id: string }>(client.api, "/api/leave-requests", { leaveTypeId: leaveType.id, startDate, endDate: startDate, startSession: "am", endSession: "pm", reason: `${input.reasonPrefix}-${Date.now()}`, }); await apiPostData(client.api, `/api/leave-requests/${created.id}/submit`, {}); return { client, leaveRequest: { requestId: created.id, startDate, }, }; } async function loginMobile(page: import("@playwright/test").Page, user: E2EUser) { await page.goto("/login"); await page.getByTestId("login-identifier").fill(user.identifier); await page.getByTestId("login-password").fill(user.password); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/mobile$/); } async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, taskId: string) { await expect .poll( async () => { return page.evaluate(async (currentTaskId) => { const request = window.indexedDB.open("hyb-oa-mobile-workflow", 1); const db = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed")); }); try { if (!db.objectStoreNames.contains("workflow_task_cache")) { return false; } const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache"); const detail = await new Promise((resolve, reject) => { const query = store.get(currentTaskId); query.onsuccess = () => resolve(query.result ?? null); query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed")); }); return Boolean(detail); } finally { db.close(); } }, taskId); }, { timeout: 15_000 }, ) .toBe(true); } async function expectMobileApprovalDetailUrl(page: import("@playwright/test").Page, taskId: string) { const encodedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); await expect(page).toHaveURL(new RegExp(`/mobile/approvals(?:/${encodedTaskId}|\\?taskId=${encodedTaskId})`)); } async function returnToMobileApprovalList(page: import("@playwright/test").Page) { await page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click(); await expect(page).toHaveURL(/\/mobile\/approvals(?:\?|$)/); } test.use({ ...devices["iPhone 13"], serviceWorkers: "block", userAgent: "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", }); test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }) => { const created = await createSubmittedLeaveViaApi({ reasonPrefix: "Playwright移动审批通过", }); const managerApi = await createAuthenticatedApiClient(MANAGER); try { const task = await waitForWorkflowTask(managerApi.api, { entityId: created.leaveRequest.requestId, entityType: "leave", }); await loginMobile(page, MANAGER); await page.goto(`/workflow/tasks/${task.id}`); await expectMobileApprovalDetailUrl(page, task.id); await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("mobile-approvals-detail")).toContainText("请假"); await waitForWorkflowTaskCache(page, task.id); await page.context().setOffline(true); await expect(page.getByText("当前离线")).toBeVisible(); await expect(page.getByTestId("mobile-approvals-approve")).toBeEnabled({ timeout: 15_000 }); await page.getByTestId("mobile-approvals-approve").click(); await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿"); await page.getByTestId("workflow-action-confirm-submit").click(); await expect(page.getByRole("main").getByText("当前离线,通过草稿已加入待同步队列。")).toBeVisible(); await page.context().setOffline(false); await page.evaluate(() => window.dispatchEvent(new Event("online"))); await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 }); } finally { await Promise.all([created.client.dispose(), managerApi.dispose()]); } }); test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => { const created = await createSubmittedLeaveViaApi({ reasonPrefix: "Playwright移动审批冲突", }); const managerApi = await createAuthenticatedApiClient(MANAGER); try { const task = await waitForWorkflowTask(managerApi.api, { entityId: created.leaveRequest.requestId, entityType: "leave", }); await loginMobile(page, MANAGER); await page.goto(`/workflow/tasks/${task.id}`); await expectMobileApprovalDetailUrl(page, task.id); await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("mobile-approvals-detail")).toContainText("请假"); await waitForWorkflowTaskCache(page, task.id); await page.context().setOffline(true); await page.getByTestId("mobile-approvals-more").click(); await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 }); await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明"); await page.getByTestId("mobile-approvals-reject").click(); await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿"); await page.getByTestId("workflow-action-confirm-submit").click(); await expect(page.getByRole("main").getByText("当前离线,驳回草稿已加入待同步队列。")).toBeVisible(); await approveWorkflowTaskViaApi(managerApi.api, task.id); await page.context().setOffline(false); await page.evaluate(() => window.dispatchEvent(new Event("online"))); await expect(page.getByRole("main").getByText(/草稿同步完成:成功 0,失败 0,冲突 1/)).toBeVisible({ timeout: 30_000 }); await returnToMobileApprovalList(page); await expect(page.getByTestId("mobile-approvals-queue")).toContainText("存在冲突", { timeout: 30_000 }); await expect(page.getByTestId("mobile-approvals-queue")).toContainText("最近错误", { timeout: 30_000 }); await expect(page.getByRole("button", { name: "查看任务" })).toBeVisible(); await expect(page.getByRole("button", { name: "丢弃草稿" })).toBeVisible(); } finally { await Promise.all([created.client.dispose(), managerApi.dispose()]); } }); test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail closed", async ({ page }) => { const taskId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const now = new Date().toISOString(); const detail = { task: { id: taskId, instanceId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", stepId: "finance", stepName: "财务审批", assigneeId: "manager", assigneeRole: "manager", status: "pending", outcome: null, comment: null, createdAt: now, completedAt: null, }, workflowInstance: { id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", entityType: "reimbursement", currentStatus: "pending", currentStepId: "finance", contextData: { standardViolations: [{ itemId: "sensitive-item", amount: "999.00" }], budgetCheck: { budgetId: "sensitive-budget", budgetCode: "B-1", controlMode: "warn", usagePercent: 90, exceededThreshold: true, exceededBudget: false }, budgetCheckStatus: "available", parseStatus: "ready", }, startedAt: now, completedAt: null, }, reimbursement: { id: reimbursementId, formNumber: "BX-CACHE-001", status: "reviewing", reimbursementDate: "2026-09-01", totalAmount: "999.00", description: "缓存边界合成报销", owner: { id: "owner", employeeNo: "E001", name: "合成申请人" }, submitter: { id: "owner", employeeNo: "E001", name: "合成申请人" }, items: [{ id: "must-not-persist" }], invoices: [{ id: "must-not-persist", amountEvidenceStatus: "manual_review" }], attachments: [{ id: "must-not-persist", materialUrl: "https://invalid.example/material" }], sharedProjection: { itemEvidenceModels: ["must-not-persist"] }, }, leaveRequest: null, attendanceCorrection: null, purchaseRequest: null, travelRequest: null, allowedActions: ["approve", "reject", "return"], history: [], }; const reimbursementDetail = { id: reimbursementId, items: [{ id: "item-1", expenseTypeId: "type-1", expenseDate: "2026-09-01", amount: "999.00", description: "合成明细", expenseType: { id: "type-1", code: "travel_taxi", name: "交通费", category: "travel", enabled: true, requiresInvoice: true }, }], invoices: [{ id: "invoice-1", status: "bound", invoiceCode: null, invoiceNumber: "FP-CACHE-1", invoiceDate: "2026-09-01", sellerName: "合成供应商", buyerName: null, documentCategory: "invoice", documentSubtype: null, totalAmount: null, originalName: "synthetic.pdf", mimeType: "application/pdf", fileSize: 100, linkedItemId: "item-1", amountEvidenceStatus: "manual_review", }], attachments: [{ id: "attachment-1", originalName: "support.pdf", mimeType: "application/pdf", fileSize: 100, fileSha256: "must-not-persist", createdAt: now, }], }; const fulfillData = (route: import("@playwright/test").Route, data: unknown, headers?: Record) => route.fulfill({ status: 200, contentType: "application/json", headers, body: JSON.stringify({ requestId: "cache-boundary", data }), }); await page.route((url) => [ "/api/auth/sso/providers", "/api/auth/wecom/config", "/api/auth/login", "/api/auth/refresh", "/api/me", "/api/system/navigation", "/api/notifications", "/api/portal/home", "/api/workflow/tasks", "/api/workflow/tasks/summary", ].includes(url.pathname), async (route) => { const url = new URL(route.request().url()); if (url.pathname === "/api/auth/sso/providers") return fulfillData(route, []); if (url.pathname === "/api/auth/wecom/config") return fulfillData(route, { enabled: false, entryEnabled: false, oauthEnabled: false, corpId: null, agentId: null, loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [], }); if (url.pathname === "/api/auth/login" || url.pathname === "/api/auth/refresh") { return fulfillData(route, { accessToken: "cache-boundary-token", expiresIn: 900, mustChangePassword: false }, { "set-cookie": "has_session=1; Path=/; SameSite=Lax" }); } if (url.pathname === "/api/me") return fulfillData(route, { id: "manager", employeeNo: "M001", name: "合成审批人", phone: null, email: "manager@example.invalid", departmentId: "department", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["manager"], bankAccount: { bankName: null, bankInstitutionCode: null, accountNumberMasked: null }, profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 70 }, }); if (url.pathname === "/api/system/navigation") return fulfillData(route, { workspace: { id: "workspace", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "mobile", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null }, sections: { main: [{ key: "workflow", href: "/workflow/tasks", label: "待我审批", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null }], admin: [], backup: [] }, recommendedRolePresets: [], }); if (url.pathname === "/api/notifications") return fulfillData(route, { page: 1, pageSize: 5, total: 0, unreadOnly: false, unreadCount: 0, items: [] }); if (url.pathname === "/api/portal/home") return fulfillData(route, { hero: { title: "工作台", subtitle: "合成测试" }, metrics: [], todos: [], notifications: [] }); if (url.pathname === "/api/workflow/tasks/summary") return fulfillData(route, { scope: "mine", pendingTotal: 1, overdueTotal: 0, earliestPendingAt: now, averagePendingAgeHours: 1, byEntityType: [], byStep: [], byAssigneeRole: [] }); return fulfillData(route, { page: 1, pageSize: 100, total: 0, items: [] }); }); await page.route(`**/api/workflow/tasks/${taskId}`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ requestId: "cache-boundary", data: detail }), })); await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ requestId: "cache-boundary", data: reimbursementDetail }), })); await loginMobile(page, MANAGER); await page.goto(`/workflow/tasks/${taskId}`); await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible(); await waitForWorkflowTaskCache(page, taskId); const rawCache = await page.evaluate(async (currentTaskId) => { const request = window.indexedDB.open("hyb-oa-mobile-workflow", 1); const db = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); try { const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache"); return await new Promise>((resolve, reject) => { const query = store.get(currentTaskId); query.onsuccess = () => resolve(query.result); query.onerror = () => reject(query.error); }); } finally { db.close(); } }, taskId); const serialized = JSON.stringify(rawCache); for (const forbiddenField of [ "items", "invoices", "attachments", "amountEvidenceStatus", "materialUrl", "fileSha256", "sharedProjection", "itemEvidenceModels", "standardViolations", "budgetCheck", "contextData", "sensitive-item", "sensitive-budget", "must-not-persist", ]) { expect(serialized).not.toContain(forbiddenField); } await page.context().setOffline(true); await expect(page.getByText("报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。")).toBeVisible(); await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled(); await expect(page.getByTestId("mobile-approvals-return")).toBeDisabled(); await expect(page.getByTestId("mobile-approvals-reject")).toBeDisabled(); await expect(page.getByTestId("mobile-approvals-transfer")).toBeDisabled(); });