1025 行
41 KiB
TypeScript
1025 行
41 KiB
TypeScript
import { mkdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
|
|
test.use({ serviceWorkers: "block" });
|
|
|
|
function envelope(data: unknown) {
|
|
return { requestId: "mock-r103-ui-consistency", data };
|
|
}
|
|
|
|
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope(data)),
|
|
});
|
|
}
|
|
|
|
const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103");
|
|
|
|
function screenshotPath(fileName: string) {
|
|
mkdirSync(screenshotDir, { recursive: true });
|
|
return path.join(screenshotDir, fileName);
|
|
}
|
|
|
|
const me = {
|
|
id: "employee-r103",
|
|
employeeNo: "HYD000103",
|
|
name: "陈工程",
|
|
phone: "13800000000",
|
|
email: "[email protected]",
|
|
departmentId: "dept-r103",
|
|
directManagerId: null,
|
|
status: "active",
|
|
notificationPushEnabled: true,
|
|
roleCodes: ["employee"],
|
|
profileCompleteness: {
|
|
hasEmergencyContact: true,
|
|
hasBankAccount: true,
|
|
hasAvatar: false,
|
|
hasIdNumber: false,
|
|
score: 50,
|
|
},
|
|
wecomBinding: null,
|
|
};
|
|
|
|
const purchaseId = "purchase-r103";
|
|
const reimbursementId = "11111111-1111-4111-8111-111111111111";
|
|
const invoiceId = "invoice-r103";
|
|
const projectId = "project-r103";
|
|
const assetId = "asset-r103";
|
|
const safePurchaseAttachmentId = "attachment-safe-r103";
|
|
const quarantinedPurchaseAttachmentId = "attachment-quarantined-r103";
|
|
const safePngBuffer = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
|
|
function navigationItem(key: string, href: string, label: string, icon: string) {
|
|
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
|
}
|
|
|
|
function purchaseSummary() {
|
|
return {
|
|
id: purchaseId,
|
|
requestNo: "CG-20260506-0001",
|
|
applicantId: me.id,
|
|
submitterId: me.id,
|
|
departmentId: me.departmentId,
|
|
legalEntityId: "legal-r103",
|
|
costCenterId: "cost-r103",
|
|
supplierId: "supplier-r103",
|
|
documentCategoryId: "category-r103",
|
|
budgetPoolId: null,
|
|
title: "办公用品采购",
|
|
reason: "日常办公",
|
|
currencyCode: "CNY",
|
|
totalAmount: "299.00",
|
|
expectedDeliveryDate: "2026-05-10",
|
|
status: "draft",
|
|
createdAt: "2026-05-06T09:00:00.000Z",
|
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
|
applicant: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
department: { id: me.departmentId, name: "研发部" },
|
|
legalEntity: { id: "legal-r103", code: "HYD", name: "海音达" },
|
|
costCenter: { id: "cost-r103", code: "RD", name: "研发部", legalEntityId: "legal-r103" },
|
|
supplier: { id: "supplier-r103", code: "SUP", name: "测试供应商" },
|
|
documentCategory: { id: "category-r103", code: "CG", name: "采购申请", entityType: "purchase_request" },
|
|
budgetPool: null,
|
|
};
|
|
}
|
|
|
|
function purchaseDetail() {
|
|
return {
|
|
...purchaseSummary(),
|
|
items: [
|
|
{
|
|
id: "purchase-item-r103",
|
|
description: "键盘",
|
|
specification: "无线",
|
|
quantity: "1.00",
|
|
unitPrice: "299.00",
|
|
lineAmount: "299.00",
|
|
isAssetCandidate: false,
|
|
createdAt: "2026-05-06T09:00:00.000Z",
|
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
|
},
|
|
],
|
|
attachments: [
|
|
{
|
|
id: safePurchaseAttachmentId,
|
|
originalName: "采购报价.png",
|
|
mimeType: "image/png",
|
|
fileSize: safePngBuffer.length,
|
|
fileSha256: null,
|
|
governanceStatus: "approved",
|
|
sensitivityLevel: "internal",
|
|
attachmentType: "quote",
|
|
downloadPolicy: "reason_required",
|
|
reviewedBy: null,
|
|
reviewedAt: null,
|
|
reviewNote: null,
|
|
archivedBy: null,
|
|
archivedAt: null,
|
|
archiveNote: null,
|
|
uploadedBy: me.id,
|
|
createdAt: "2026-05-06T09:20:00.000Z",
|
|
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
},
|
|
{
|
|
id: quarantinedPurchaseAttachmentId,
|
|
originalName: "历史隔离附件.pdf",
|
|
mimeType: "application/pdf",
|
|
fileSize: 128,
|
|
fileSha256: null,
|
|
governanceStatus: "quarantined",
|
|
sensitivityLevel: "internal",
|
|
attachmentType: "quote",
|
|
downloadPolicy: "standard",
|
|
reviewedBy: null,
|
|
reviewedAt: null,
|
|
reviewNote: null,
|
|
archivedBy: null,
|
|
archivedAt: null,
|
|
archiveNote: null,
|
|
uploadedBy: me.id,
|
|
createdAt: "2026-05-06T09:15:00.000Z",
|
|
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
},
|
|
],
|
|
pendingWorkflow: null,
|
|
archiveOrder: null,
|
|
permissions: {
|
|
canEditDraft: true,
|
|
canSubmit: true,
|
|
canWithdraw: false,
|
|
canArchiveOrder: false,
|
|
},
|
|
riskFlags: [],
|
|
};
|
|
}
|
|
|
|
function invoiceItem() {
|
|
return {
|
|
id: invoiceId,
|
|
status: "available",
|
|
ocrType: "mixed",
|
|
ocrProvider: "mock",
|
|
ocrAttempts: 1,
|
|
ocrLastError: null,
|
|
ocrCompletedAt: "2026-05-06T09:00:00.000Z",
|
|
invoiceCode: "044002600111",
|
|
invoiceNumber: "12345678",
|
|
invoiceDate: "2026-05-06",
|
|
amount: "100.00",
|
|
taxAmount: "6.00",
|
|
totalAmount: "106.00",
|
|
linkedReimbursementId: null,
|
|
linkedItemId: null,
|
|
linkedTravelOrderId: null,
|
|
originalName: "发票.pdf",
|
|
mimeType: "application/pdf",
|
|
fileSize: 1024,
|
|
verification: null,
|
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
createdAt: "2026-05-06T09:00:00.000Z",
|
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
|
};
|
|
}
|
|
|
|
function reimbursementForBind() {
|
|
return {
|
|
id: reimbursementId,
|
|
formNumber: null,
|
|
status: "draft",
|
|
reimbursementDate: "2026-05-06",
|
|
totalAmount: "106.00",
|
|
description: "发票绑定测试",
|
|
payment: {
|
|
paymentVoucherNo: null,
|
|
paymentDate: null,
|
|
paymentChannel: null,
|
|
paidAt: null,
|
|
paidBy: null,
|
|
},
|
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
project: null,
|
|
pendingWorkflow: null,
|
|
createdAt: "2026-05-06T09:00:00.000Z",
|
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
|
};
|
|
}
|
|
|
|
function projectListItem() {
|
|
return {
|
|
id: projectId,
|
|
projectNo: "PRJ-20260718-001",
|
|
name: "内部候选项目",
|
|
clientName: "合成客户",
|
|
clientAlias: "合成客户",
|
|
status: "active",
|
|
startsAt: "2026-07-01",
|
|
targetEndAt: "2026-08-31",
|
|
actualEndAt: null,
|
|
summary: "用于验证中等宽度下项目操作列可达",
|
|
isConfidential: false,
|
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
manager: null,
|
|
counts: {
|
|
members: 2,
|
|
milestones: 3,
|
|
tasks: 5,
|
|
risks: 1,
|
|
overdueMilestones: 0,
|
|
highPriorityTasks: 1,
|
|
openRisks: 1,
|
|
},
|
|
updatedAt: "2026-07-18T08:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
function assetListItem() {
|
|
return {
|
|
id: assetId,
|
|
assetNo: "FA-20260718-000001",
|
|
name: "研发测试笔记本电脑",
|
|
assetType: "fixed_asset",
|
|
sensitivityLevel: "internal",
|
|
categoryId: "asset-category-r103",
|
|
legalEntityId: "legal-r103",
|
|
costCenterId: "cost-r103",
|
|
purchaseRequestId: null,
|
|
serialNo: "SYNTHETIC-R103",
|
|
location: "办公室 A 区",
|
|
status: "in_use",
|
|
assigneeEmployeeId: me.id,
|
|
ownerEmployeeId: me.id,
|
|
keeperEmployeeId: me.id,
|
|
archiveLocation: null,
|
|
externalRef: null,
|
|
sourceHash: null,
|
|
verificationNo: null,
|
|
confidentialityNote: null,
|
|
metadata: null,
|
|
lastVerifiedAt: "2026-07-17T08:00:00.000Z",
|
|
expiresAt: null,
|
|
activatedAt: "2026-07-01",
|
|
retiredAt: null,
|
|
createdAt: "2026-07-01T08:00:00.000Z",
|
|
updatedAt: "2026-07-18T08:00:00.000Z",
|
|
category: {
|
|
id: "asset-category-r103",
|
|
code: "COMPUTER",
|
|
name: "电脑设备",
|
|
documentCategoryId: null,
|
|
documentCategory: null,
|
|
},
|
|
legalEntity: { id: "legal-r103", code: "HYD", name: "海音达" },
|
|
costCenter: { id: "cost-r103", code: "RD", name: "研发部", legalEntityId: "legal-r103" },
|
|
purchaseRequest: null,
|
|
assignee: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } },
|
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } },
|
|
keeper: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } },
|
|
permissions: { canEdit: false },
|
|
};
|
|
}
|
|
|
|
async function mockApis(page: Page, options: { roleCodes?: string[] } = {}) {
|
|
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/sso/providers") {
|
|
await fulfillJson(route, []);
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/auth/wecom/config") {
|
|
await fulfillJson(route, {
|
|
enabled: false,
|
|
entryEnabled: false,
|
|
oauthEnabled: false,
|
|
corpId: "",
|
|
agentId: "",
|
|
loginUrl: null,
|
|
bindEnabled: false,
|
|
defaultReturnTo: "/portal",
|
|
status: "disabled",
|
|
statusReasons: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
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-r103-token", expiresIn: 900, mustChangePassword: false })),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/me") {
|
|
await fulfillJson(route, { ...me, roleCodes: options.roleCodes ?? me.roleCodes });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/system/navigation") {
|
|
await fulfillJson(route, {
|
|
workspace: {
|
|
id: "workspace-r103",
|
|
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", "工作台", "home_app_logo"),
|
|
navigationItem("reimbursements", "/reimbursements", "报销", "receipt_long"),
|
|
navigationItem("purchase", "/purchase-requests", "采购", "shopping_cart"),
|
|
navigationItem("projects", "/projects", "项目", "topic"),
|
|
navigationItem("assets", "/assets", "资产", "inventory_2"),
|
|
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
|
],
|
|
admin: [],
|
|
backup: [
|
|
navigationItem("payment", "/payment-batches", "付款", "payments"),
|
|
navigationItem("dashboard", "/dashboard", "数据看板", "monitoring"),
|
|
],
|
|
},
|
|
recommendedRolePresets: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/notifications") {
|
|
await fulfillJson(route, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/portal/home") {
|
|
await fulfillJson(route, {
|
|
workspace: { id: "workspace-r103", persisted: true, profile: "small_team_internal" },
|
|
greeting: { name: me.name },
|
|
cards: [],
|
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
|
myApplications: [],
|
|
riskSummary: null,
|
|
announcements: [],
|
|
quickLinks: [],
|
|
todayPresence: null,
|
|
teamPresence: null,
|
|
weeklyReport: null,
|
|
weeklySummary: null,
|
|
todoSummary: null,
|
|
projectSummary: null,
|
|
peopleLifecycle: null,
|
|
adoptionSummary: null,
|
|
recentActivities: [],
|
|
teamStatusBoard: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/master-data/legal-entities") {
|
|
await fulfillJson(route, { includeArchived: false, items: [{ id: "legal-r103", code: "HYD", name: "海音达", shortName: null, currencyCode: "CNY", taxNo: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/master-data/cost-centers") {
|
|
await fulfillJson(route, { includeArchived: false, items: [{ id: "cost-r103", legalEntityId: "legal-r103", code: "RD", name: "研发部", parentId: null, ownerEmployeeId: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/master-data/suppliers") {
|
|
await fulfillJson(route, { includeArchived: false, items: [{ id: "supplier-r103", code: "SUP", name: "测试供应商", taxNo: null, contactName: null, contactPhone: null, bankName: null, bankAccount: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/master-data/document-categories") {
|
|
await fulfillJson(route, { includeArchived: false, items: [{ id: "category-r103", code: "CG", name: "采购申请", entityType: "purchase_request", sequencePrefix: "CG", numberLength: 4, resetPeriod: "daily", enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/purchase-requests" && request.method() === "GET") {
|
|
const summary = purchaseSummary();
|
|
const queryStatus = url.searchParams.get("status");
|
|
const items = !queryStatus || queryStatus === summary.status ? [summary] : [];
|
|
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, mine: true, items });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/projects" && request.method() === "GET") {
|
|
await fulfillJson(route, { items: [projectListItem()] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/contracts-registry/expiring" && request.method() === "GET") {
|
|
await fulfillJson(route, { days: 30, total: 0, items: [] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/contracts-registry" && request.method() === "GET") {
|
|
await fulfillJson(route, { items: [] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/payment-batches" && request.method() === "GET") {
|
|
await fulfillJson(route, { items: [] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/ap-operations" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
summary: {
|
|
pendingPaymentCount: 0,
|
|
partialMatchCount: 0,
|
|
exceptionCount: 0,
|
|
budgetOverspendCount: 0,
|
|
},
|
|
pendingPaymentBatches: [],
|
|
failedPaymentBatchItems: [],
|
|
exceptionRiskFlags: [],
|
|
budgetOverspendRisks: [],
|
|
partialMatchItems: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/ap-operations/reimbursement-ledger" && request.method() === "GET") {
|
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/risk-flags" && request.method() === "GET") {
|
|
await fulfillJson(route, { items: [] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/asset-categories" && request.method() === "GET") {
|
|
await fulfillJson(route, {
|
|
includeArchived: false,
|
|
items: [
|
|
{
|
|
id: "asset-category-r103",
|
|
code: "COMPUTER",
|
|
name: "电脑设备",
|
|
documentCategoryId: null,
|
|
enabled: true,
|
|
archivedAt: null,
|
|
createdAt: "2026-07-01T08:00:00.000Z",
|
|
updatedAt: "2026-07-18T08:00:00.000Z",
|
|
documentCategory: null,
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/assets" && request.method() === "GET") {
|
|
await fulfillJson(route, { mine: true, items: [assetListItem()] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/assets" && request.method() === "POST") {
|
|
const payload = request.postDataJSON() as { name?: string };
|
|
if (payload.name === "合成失败资产") {
|
|
await route.fulfill({
|
|
status: 400,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
requestId: "mock-r103-asset-create-failure",
|
|
error: { code: "VALID_001", message: "资产保存失败,请检查填写内容。" },
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
await fulfillJson(route, {
|
|
asset: {
|
|
...assetListItem(),
|
|
...payload,
|
|
id: assetId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === `/api/assets/${assetId}` && request.method() === "GET") {
|
|
await fulfillJson(route, { asset: assetListItem() });
|
|
return;
|
|
}
|
|
|
|
if (pathName === `/api/purchase-requests/${purchaseId}` && request.method() === "PATCH") {
|
|
await fulfillJson(route, { ...purchaseDetail(), ...request.postDataJSON() });
|
|
return;
|
|
}
|
|
|
|
if (pathName === `/api/purchase-requests/${purchaseId}` && request.method() === "GET") {
|
|
await fulfillJson(route, purchaseDetail());
|
|
return;
|
|
}
|
|
|
|
if (pathName === `/api/purchase-requests/${purchaseId}/attachments` && request.method() === "POST") {
|
|
if (request.postDataBuffer()?.includes(Buffer.from('filename="unsupported.txt"'))) {
|
|
await route.fulfill({
|
|
status: 400,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
requestId: "mock-r103-unsupported-attachment",
|
|
error: {
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
details: { file: "unsupported_type", reasonCode: "purchase_attachment.unsupported_type" },
|
|
},
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
await fulfillJson(route, {
|
|
attachment: {
|
|
id: "attachment-r103",
|
|
originalName: "采购报价.png",
|
|
mimeType: "image/png",
|
|
fileSize: safePngBuffer.length,
|
|
fileSha256: null,
|
|
governanceStatus: "approved",
|
|
sensitivityLevel: "internal",
|
|
attachmentType: "quote",
|
|
downloadPolicy: "standard",
|
|
reviewedBy: null,
|
|
reviewedAt: null,
|
|
reviewNote: null,
|
|
archivedBy: null,
|
|
archivedAt: null,
|
|
archiveNote: null,
|
|
uploadedBy: me.id,
|
|
createdAt: "2026-05-06T09:20:00.000Z",
|
|
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
pathName === `/api/purchase-requests/${purchaseId}/attachments/${safePurchaseAttachmentId}/download` &&
|
|
request.method() === "POST"
|
|
) {
|
|
expect(request.headers().authorization).toBe("Bearer mock-r103-token");
|
|
expect(request.headers()["content-type"]).toContain("application/json");
|
|
expect(request.postDataJSON()).toEqual({ reason: "采购复核" });
|
|
await route.fulfill({
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/octet-stream",
|
|
"content-disposition": 'attachment; filename="purchase-attachment.png"',
|
|
"x-content-type-options": "nosniff",
|
|
},
|
|
body: safePngBuffer,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/invoice-pool") {
|
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceItem()] });
|
|
return;
|
|
}
|
|
|
|
if (pathName === "/api/reimbursements") {
|
|
await fulfillJson(route, { page: 1, pageSize: 50, total: 1, scope: "own", items: [reimbursementForBind()] });
|
|
return;
|
|
}
|
|
|
|
await fulfillJson(route, {});
|
|
});
|
|
}
|
|
|
|
async function login(page: Page) {
|
|
await page.goto("/login");
|
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
|
await page.getByTestId("login-password").fill("EmployeePassword1234");
|
|
await page.getByTestId("login-submit").click();
|
|
await expect(page).toHaveURL(/\/portal/);
|
|
}
|
|
|
|
async function expectResponsiveBusinessTable(
|
|
page: Page,
|
|
target: { path: string; heading: string; tableTestId: string; screenshotName: string },
|
|
) {
|
|
await page.goto(target.path);
|
|
await expect(page.getByRole("heading", { name: target.heading, exact: true })).toBeVisible();
|
|
|
|
const table = page.getByTestId(target.tableTestId);
|
|
await expect(table).toBeVisible();
|
|
const scrollRegion = table.locator("xpath=ancestor::*[@data-testid='business-table-scroll']").first();
|
|
await expect(scrollRegion).toBeVisible();
|
|
|
|
const metrics = await scrollRegion.evaluate((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
element.scrollLeft = element.scrollWidth;
|
|
return {
|
|
clientWidth: element.clientWidth,
|
|
scrollWidth: element.scrollWidth,
|
|
overflowX: style.overflowX,
|
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
};
|
|
});
|
|
expect(["auto", "scroll"]).toContain(metrics.overflowX);
|
|
expect(metrics.scrollWidth).toBeGreaterThanOrEqual(metrics.clientWidth);
|
|
expect(metrics.documentOverflow).toBeLessThanOrEqual(2);
|
|
|
|
const operation = table.getByRole("link").or(table.getByRole("button")).last();
|
|
await expect(operation).toBeVisible();
|
|
await operation.click({ trial: true });
|
|
await page.screenshot({ path: screenshotPath(target.screenshotName), fullPage: true });
|
|
}
|
|
|
|
async function expectLedgerHeaderActions(
|
|
page: Page,
|
|
target: { path: string; heading: string; expectedActions: string[] },
|
|
) {
|
|
await page.goto(target.path);
|
|
const heading = page.getByRole("heading", { level: 1, name: target.heading, exact: true });
|
|
await expect(heading).toBeVisible();
|
|
|
|
const header = heading.locator("xpath=ancestor::header[1]");
|
|
await expect(header).toHaveClass(/page-header-shell/);
|
|
const actionRegion = header.locator(":scope > .page-header-shell__actions");
|
|
|
|
if (target.expectedActions.length === 0) {
|
|
await expect(actionRegion).toHaveCount(0);
|
|
return;
|
|
}
|
|
|
|
await expect(actionRegion).toHaveCount(1);
|
|
const actionLabels = await actionRegion.locator("button, a").evaluateAll((elements) =>
|
|
elements.map((element) => (element.textContent ?? "").replace(/\s+/g, " ").trim()),
|
|
);
|
|
expect(actionLabels).toHaveLength(target.expectedActions.length);
|
|
expect(actionLabels).toEqual(target.expectedActions);
|
|
expect(actionLabels.length).toBeLessThanOrEqual(2);
|
|
expect(actionLabels.filter((label) => label === "更多").length).toBeLessThanOrEqual(1);
|
|
expect(actionLabels.filter((label) => label !== "更多").length).toBeLessThanOrEqual(1);
|
|
expect(actionLabels.filter((label) => /^(筛选|导出(?:\s.*)?|刷新|显示测试数据)$/.test(label))).toEqual([]);
|
|
}
|
|
|
|
test.describe("R103 cross page UI consistency", () => {
|
|
test.beforeEach(async ({ page }, testInfo) => {
|
|
const roleCodes = testInfo.title.includes("七个主要业务台账")
|
|
? ["employee", "manager", "finance", "administration"]
|
|
: testInfo.title.includes("资产长表单")
|
|
? ["employee", "administration"]
|
|
: undefined;
|
|
await mockApis(page, {
|
|
roleCodes,
|
|
});
|
|
await login(page);
|
|
});
|
|
|
|
test("七个主要业务台账标题区保持一主操作加至多一个更多,列表工具不常驻", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 960 });
|
|
const targets = [
|
|
{ path: "/purchase-requests", heading: "采购", expectedActions: ["新建采购"] },
|
|
{ path: "/contracts-registry", heading: "合同", expectedActions: ["新增合同"] },
|
|
{ path: "/projects", heading: "项目", expectedActions: ["新建项目"] },
|
|
{ path: "/assets", heading: "资产", expectedActions: ["新增资产", "更多"] },
|
|
{ path: "/payment-batches", heading: "付款", expectedActions: ["新建批次"] },
|
|
{ path: "/ap-operations", heading: "应付账款", expectedActions: [] },
|
|
{ path: "/invoice-pool", heading: "票据", expectedActions: ["上传票据"] },
|
|
];
|
|
|
|
for (const target of targets) {
|
|
await expectLedgerHeaderActions(page, target);
|
|
|
|
if (target.path === "/assets") {
|
|
const assetHeader = page
|
|
.getByRole("heading", { level: 1, name: "资产", exact: true })
|
|
.locator("xpath=ancestor::header[1]");
|
|
await expect(assetHeader.getByRole("button", { name: "资产分类", exact: true })).toHaveCount(0);
|
|
await assetHeader.getByRole("button", { name: "更多", exact: true }).click();
|
|
await page.getByRole("menuitem", { name: "资产分类", exact: true }).click();
|
|
await expect(page.getByTestId("asset-category-manager")).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test("采购页使用安全格式上传并通过认证 Blob 下载", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 960 });
|
|
await page.goto("/purchase-requests");
|
|
|
|
await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购")).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "采购" })).toBeVisible();
|
|
const purchaseHeader = page.locator(".page-header-shell").filter({ hasText: "采购" }).first();
|
|
await expect(purchaseHeader.getByRole("button", { name: "筛选" })).toHaveCount(0);
|
|
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
|
|
await expect(page.getByTestId("purchase-advanced-filters")).toBeVisible();
|
|
await expect(page.getByTestId("purchase-attachment-upload")).toHaveCount(0);
|
|
await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0);
|
|
await expect(page.getByText("付款处理")).toHaveCount(0);
|
|
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
|
|
|
|
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
|
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
|
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
|
|
await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0);
|
|
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
|
await expect(page.getByText("支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。")).toBeVisible();
|
|
await expect(page.getByText("Choose File")).toHaveCount(0);
|
|
|
|
const attachmentInput = page.getByTestId("purchase-attachment-file-input");
|
|
await expect(attachmentInput).toHaveAttribute(
|
|
"accept",
|
|
".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp",
|
|
);
|
|
await attachmentInput.setInputFiles({
|
|
name: "采购报价.png",
|
|
mimeType: "image/png",
|
|
buffer: safePngBuffer,
|
|
});
|
|
await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.png");
|
|
|
|
await attachmentInput.setInputFiles({
|
|
name: "unsupported.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("synthetic unsupported attachment", "utf8"),
|
|
});
|
|
await expect(page.getByRole("main").getByText("仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。")).toBeVisible();
|
|
|
|
let popupOpened = false;
|
|
page.on("popup", () => {
|
|
popupOpened = true;
|
|
});
|
|
await page.getByTestId(`purchase-attachment-download-desktop-${safePurchaseAttachmentId}`).click();
|
|
await expect(page.getByRole("heading", { name: "填写下载用途" })).toBeVisible();
|
|
await page.getByRole("textbox", { name: "下载用途" }).fill("采购复核");
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await page.getByRole("button", { name: "继续下载" }).click();
|
|
const download = await downloadPromise;
|
|
expect(download.suggestedFilename()).toBe("采购报价.png");
|
|
expect(popupOpened).toBe(false);
|
|
|
|
await expect(
|
|
page.getByTestId(`purchase-attachment-download-desktop-${quarantinedPurchaseAttachmentId}`),
|
|
).toBeDisabled();
|
|
await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true });
|
|
});
|
|
|
|
test("采购与资产列表、详情各自只请求职责内数据并恢复 URL 筛选", async ({ page }) => {
|
|
const requests: Array<{ method: string; pathname: string; search: string }> = [];
|
|
page.on("request", (request) => {
|
|
const url = new URL(request.url());
|
|
if (url.pathname.startsWith("/api/purchase-requests") || url.pathname.startsWith("/api/assets")) {
|
|
requests.push({ method: request.method(), pathname: url.pathname, search: url.search });
|
|
}
|
|
});
|
|
|
|
await page.goto("/purchase-requests?status=draft&scope=own&supplierId=supplier-r103");
|
|
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
|
|
expect(requests.some((item) => item.method === "GET" && item.pathname === `/api/purchase-requests/${purchaseId}`)).toBe(false);
|
|
requests.length = 0;
|
|
|
|
await page.getByTestId("purchase-row-open").click();
|
|
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
|
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
|
expect(requests.some((item) => item.method === "GET" && item.pathname === "/api/purchase-requests")).toBe(false);
|
|
await page.getByRole("button", { name: "返回采购列表", exact: true }).click();
|
|
await expect(page).toHaveURL(/\/purchase-requests\?status=draft&scope=own&supplierId=supplier-r103$/);
|
|
|
|
requests.length = 0;
|
|
await page.goto("/assets?q=研发&status=in_use&scope=own");
|
|
await expect(page.getByTestId("assets-table")).toBeVisible();
|
|
expect(requests.some((item) => item.method === "GET" && item.pathname === `/api/assets/${assetId}`)).toBe(false);
|
|
requests.length = 0;
|
|
|
|
await page.getByRole("button", { name: "查看详情", exact: true }).first().click();
|
|
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
|
await expect(page.getByTestId("asset-name")).toHaveValue("研发测试笔记本电脑");
|
|
expect(requests.some((item) => item.method === "GET" && item.pathname === "/api/assets")).toBe(false);
|
|
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
|
|
await expect(page).toHaveURL(/\/assets\?/);
|
|
const restoredAssetUrl = new URL(page.url());
|
|
expect(restoredAssetUrl.pathname).toBe("/assets");
|
|
expect(Object.fromEntries(restoredAssetUrl.searchParams)).toEqual({ q: "研发", status: "in_use", scope: "own" });
|
|
});
|
|
|
|
test("采购与资产长表单详情对 403/404 使用同一安全错误态", async ({ page }) => {
|
|
const safeError = (message: string) => JSON.stringify({
|
|
requestId: "mock-business-detail-denied",
|
|
error: { code: "NOT_FOUND", message },
|
|
});
|
|
await page.route("**/api/purchase-requests/missing-purchase", async (route) => {
|
|
await route.fulfill({ status: 404, contentType: "application/json", body: safeError("内部采购对象不存在") });
|
|
});
|
|
await page.route("**/api/assets/missing-asset", async (route) => {
|
|
await route.fulfill({ status: 403, contentType: "application/json", body: safeError("内部资产对象无权访问") });
|
|
});
|
|
|
|
await page.goto("/purchase-requests/missing-purchase");
|
|
await expect(page.getByText("无法查看该记录,记录可能不存在或你无权访问。", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("内部采购对象不存在")).toHaveCount(0);
|
|
|
|
await page.goto("/assets/missing-asset");
|
|
await expect(page.getByText("无法查看该记录,记录可能不存在或你无权访问。", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("内部资产对象无权访问")).toHaveCount(0);
|
|
});
|
|
|
|
test("资产创建路由不会把列表查看权限当成 asset.manage", async ({ page }) => {
|
|
await page.goto("/assets");
|
|
await expect(page).not.toHaveURL(/\/403$/);
|
|
await page.goto("/assets/new");
|
|
await expect(page).toHaveURL(/\/403$/);
|
|
});
|
|
|
|
test("票据绑定报销单不展示 UUID 兜底", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 960 });
|
|
await page.goto("/invoice-pool");
|
|
|
|
await expect(page.getByRole("heading", { name: "票据" })).toBeVisible();
|
|
const invoiceHeader = page.locator(".page-header-shell").filter({ hasText: "票据" }).first();
|
|
await expect(invoiceHeader.getByRole("button", { name: "绑定" })).toHaveCount(0);
|
|
await expect(invoiceHeader.getByTestId("invoice-export")).toHaveCount(0);
|
|
await expect(page.getByTestId("invoice-detail-panel")).toHaveCount(0);
|
|
await page.getByRole("button", { name: "绑定" }).click();
|
|
await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06");
|
|
await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId);
|
|
await expect(page.getByText("/invoice-pool")).toHaveCount(0);
|
|
await page.screenshot({ path: screenshotPath("invoice-pool-desktop.png"), fullPage: true });
|
|
});
|
|
|
|
test("移动宽度下关键页面仍保持浅色中文工作台风格", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.goto("/invoice-pool");
|
|
|
|
await expect(page.getByRole("heading", { name: "票据" })).toBeVisible();
|
|
await expect(page.getByTestId("invoice-pool-mobile-list")).toBeVisible();
|
|
await expect(page.getByText("Choose File")).toHaveCount(0);
|
|
await page.screenshot({ path: screenshotPath("invoice-pool-mobile.png"), fullPage: true });
|
|
});
|
|
|
|
test("采购长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.goto(`/purchase-requests/${purchaseId}`);
|
|
const title = page.getByTestId("purchase-title");
|
|
await expect(title).toBeVisible();
|
|
await title.fill("办公用品采购(未保存)");
|
|
|
|
const returnToList = page.getByRole("button", { name: "返回采购列表", exact: true });
|
|
await returnToList.click();
|
|
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存");
|
|
await page.getByTestId("unsaved-changes-continue").click();
|
|
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
|
await expect(title).toHaveValue("办公用品采购(未保存)");
|
|
|
|
await returnToList.click();
|
|
await page.getByTestId("unsaved-changes-discard").click();
|
|
await expect(page).toHaveURL(/\/purchase-requests$/);
|
|
await expect(page.getByTestId("purchase-row-open")).toBeVisible();
|
|
|
|
await page.goto(`/purchase-requests/${purchaseId}`);
|
|
await page.getByTestId("purchase-title").fill("办公用品采购(已保存)");
|
|
await page.getByTestId("purchase-save").click();
|
|
await expect(page.getByText("采购草稿已保存")).toBeVisible();
|
|
await page.getByRole("link", { name: "票据", exact: true }).first().click();
|
|
await expect(page).toHaveURL(/\/invoice-pool$/);
|
|
await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0);
|
|
});
|
|
|
|
test("资产长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
const requestedPaths: string[] = [];
|
|
page.on("request", (request) => requestedPaths.push(new URL(request.url()).pathname));
|
|
await page.goto("/assets");
|
|
await page.getByTestId("asset-create").click();
|
|
await expect(page).toHaveURL(/\/assets\/new$/);
|
|
expect(requestedPaths).not.toContain("/api/assets/new");
|
|
const name = page.getByTestId("asset-name");
|
|
await expect(name).toBeVisible();
|
|
await name.fill("合成未保存资产");
|
|
|
|
const returnToList = page.getByRole("button", { name: "返回资产列表", exact: true });
|
|
await returnToList.click();
|
|
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存");
|
|
await page.getByTestId("unsaved-changes-continue").click();
|
|
await expect(page).toHaveURL(/\/assets\/new$/);
|
|
await expect(name).toHaveValue("合成未保存资产");
|
|
|
|
await returnToList.click();
|
|
await page.getByTestId("unsaved-changes-discard").click();
|
|
await expect(page).toHaveURL(/\/assets$/);
|
|
await expect(page.getByTestId("asset-row")).toBeVisible();
|
|
|
|
await page.getByTestId("asset-create").click();
|
|
await expect(page).toHaveURL(/\/assets\/new$/);
|
|
await page.getByTestId("asset-name").fill("合成已保存资产");
|
|
await page.getByTestId("asset-category").selectOption("asset-category-r103");
|
|
await page.getByTestId("asset-legal-entity").selectOption("legal-r103");
|
|
await page.getByTestId("asset-cost-center").selectOption("cost-r103");
|
|
await page.getByTestId("asset-save").click();
|
|
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
|
await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0);
|
|
await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "false");
|
|
|
|
await page.goBack();
|
|
await expect(page).toHaveURL(/\/assets$/);
|
|
await page.goForward();
|
|
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
|
|
|
await page.goBack();
|
|
await page.getByTestId("asset-create").click();
|
|
await page.getByTestId("asset-name").fill("合成失败资产");
|
|
await page.getByTestId("asset-category").selectOption("asset-category-r103");
|
|
await page.getByTestId("asset-legal-entity").selectOption("legal-r103");
|
|
await page.getByTestId("asset-cost-center").selectOption("cost-r103");
|
|
const failedCreateResponse = page.waitForResponse(
|
|
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/assets",
|
|
);
|
|
await page.getByTestId("asset-save").click();
|
|
const failedResponse = await failedCreateResponse;
|
|
expect(failedResponse.ok()).toBe(false);
|
|
expect(failedResponse.status()).toBe(400);
|
|
await expect(page).toHaveURL(/\/assets\/new$/);
|
|
await expect(page.getByTestId("asset-name")).toHaveValue("合成失败资产");
|
|
await expect(page.getByRole("main").getByText("资产保存失败,请检查填写内容。", { exact: true })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
|
|
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存");
|
|
await page.getByTestId("unsaved-changes-continue").click();
|
|
await expect(page).toHaveURL(/\/assets\/new$/);
|
|
await expect(page.getByTestId("asset-name")).toHaveValue("合成失败资产");
|
|
});
|
|
|
|
test("共享未保存保护覆盖浏览器返回取消、确认与刷新拦截", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.getByRole("link", { name: "采购", exact: true }).first().click();
|
|
await expect(page).toHaveURL(/\/purchase-requests$/);
|
|
await page.getByTestId("purchase-row-open").click();
|
|
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
|
const title = page.getByTestId("purchase-title");
|
|
await title.fill("浏览器历史未保存验证");
|
|
await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "true");
|
|
|
|
await page.goBack();
|
|
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存");
|
|
await page.getByTestId("unsaved-changes-continue").click();
|
|
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
|
await expect(title).toHaveValue("浏览器历史未保存验证");
|
|
|
|
const beforeUnloadPromise = page.waitForEvent("dialog");
|
|
const reloadPromise = page.reload({ waitUntil: "domcontentloaded", timeout: 10_000 }).catch(() => null);
|
|
const beforeUnload = await beforeUnloadPromise;
|
|
expect(beforeUnload.type()).toBe("beforeunload");
|
|
await beforeUnload.dismiss();
|
|
await reloadPromise;
|
|
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
|
await expect(title).toHaveValue("浏览器历史未保存验证");
|
|
|
|
const acceptedBeforeUnloadPromise = page.waitForEvent("dialog");
|
|
const acceptedReloadPromise = page.reload({ waitUntil: "domcontentloaded", timeout: 10_000 });
|
|
const acceptedBeforeUnload = await acceptedBeforeUnloadPromise;
|
|
expect(acceptedBeforeUnload.type()).toBe("beforeunload");
|
|
await acceptedBeforeUnload.accept();
|
|
await acceptedReloadPromise;
|
|
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
|
await expect(title).toHaveValue("办公用品采购");
|
|
await title.fill("浏览器历史再次未保存验证");
|
|
|
|
await page.goBack();
|
|
await expect(page.getByTestId("unsaved-changes-dialog")).toBeVisible();
|
|
await page.getByTestId("unsaved-changes-discard").click();
|
|
await expect(page).toHaveURL(/\/purchase-requests$/);
|
|
});
|
|
|
|
test("/projects /assets /reimbursements 在中等宽度下操作列可达且页面不横溢", async ({ page }) => {
|
|
const targets = [
|
|
{ path: "/projects", heading: "项目", tableTestId: "projects-table", screenshotName: "projects-768x1024.png" },
|
|
{ path: "/assets", heading: "资产", tableTestId: "assets-table", screenshotName: "assets-768x1024.png" },
|
|
{ path: "/reimbursements", heading: "我的报销", tableTestId: "reimbursements-table", screenshotName: "reimbursements-768x1024.png" },
|
|
];
|
|
|
|
for (const viewport of [
|
|
{ width: 768, height: 1024 },
|
|
{ width: 1024, height: 768 },
|
|
]) {
|
|
await page.setViewportSize(viewport);
|
|
for (const target of targets) {
|
|
await expectResponsiveBusinessTable(page, {
|
|
...target,
|
|
screenshotName: target.screenshotName.replace("768x1024", `${viewport.width}x${viewport.height}`),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|