360 行
16 KiB
TypeScript
360 行
16 KiB
TypeScript
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<LeaveTypeListResponse>(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<WorkflowSubmitResponse>(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<IDBDatabase>((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<unknown>((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<string, string>) => 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: "[email protected]",
|
|
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<IDBDatabase>((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<Record<string, unknown>>((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();
|
|
});
|