完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)

这个提交包含在:
2026-07-18 15:46:14 +08:00
父节点 58c8decef9
当前提交 5d0a5f9a38
共修改 134 个文件,包含 10790 行新增和 1413 行删除
+2 -1
查看文件
@@ -85,7 +85,8 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await (await openNavLink(page, "资产")).click();
await expect(page.getByRole("heading", { name: "资产" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("asset-category-code")).toHaveCount(0);
await page.getByRole("button", { name: "资产分类" }).click();
await page.locator(".page-header-shell").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-category-manager")).toBeVisible();
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
@@ -0,0 +1,405 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/07-18-small-team-internal-readiness");
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";
function envelope(data: unknown) {
return { requestId: "mock-internal-readiness-ui", 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);
}
function buildMe() {
return {
id: "employee-internal-readiness",
employeeNo: "SYN005",
name: "合成员工",
phone: "13800000000",
email: "[email protected]",
departmentId: "department-internal-readiness",
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: ["employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: false,
hasAvatar: false,
hasIdNumber: false,
score: 40,
},
wecomBinding: null,
};
}
function archiveItem() {
return {
id: "archive-internal-readiness",
spNoPrefix: "SYNTHETIC-ARCHIVE-PREFIX-WITH-A-DELIBERATELY-LONG-VALUE-20260718",
templateIdPrefix: "SYNTHETIC-TEMPLATE",
templateType: "leave",
spName: "合成请假归档记录(用于验证窄屏长文案安全换行)",
normalizedStatus: "approved",
applicant: {
id: "employee-internal-readiness",
employeeNo: "SYN005",
name: "合成长姓名用于验证移动端不会横向裁切",
},
appliedAt: "2026-07-17T01:00:00.000Z",
submittedAt: "2026-07-17T01:05:00.000Z",
completedAt: "2026-07-17T02:00:00.000Z",
statusObservedAt: "2026-07-17T02:00:00.000Z",
lastSource: "callback",
lastCallbackAt: "2026-07-17T02:00:00.000Z",
lastCompensationAt: null,
readabilityStatus: "readable",
singleTapApproveRisk: false,
templateAdjustmentRequired: false,
participantCount: 2,
actionCount: 1,
fieldCount: 2,
findingCount: 0,
openFindingCount: 0,
};
}
function archiveDetail() {
return {
...archiveItem(),
participants: [
{
id: "participant-internal-readiness",
role: "applicant",
nodeIndex: 0,
employee: archiveItem().applicant,
isCurrent: false,
mappingStatus: "matched",
},
],
actions: [
{
id: "action-internal-readiness",
actionType: "approve",
operator: { id: "manager-internal-readiness", employeeNo: "SYN006", name: "合成审批人" },
actionTime: "2026-07-17T02:00:00.000Z",
nodeIndex: 1,
source: "callback",
},
],
fields: [
{
id: "field-internal-readiness-1",
fieldKey: "long_business_field",
fieldIndex: 0,
fieldName: "这是一个用于验证移动归档长字段名称能够安全换行而不会撑破页面的合成字段",
fieldType: "text",
sensitiveLevel: "internal",
displayAllowed: true,
},
{
id: "field-internal-readiness-2",
fieldKey: "restricted_summary",
fieldIndex: 1,
fieldName: "受限资料摘要",
fieldType: "text",
sensitiveLevel: "restricted",
displayAllowed: false,
},
],
findings: [],
};
}
type MockState = {
archiveMode: "empty" | "record";
};
async function mockApis(page: Page, state: MockState) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const pathName = new URL(request.url()).pathname;
if (pathName === "/api/auth/capabilities") {
await fulfillJson(route, {
emailDelivery: { ready: false },
emailLoginCode: { enabled: false },
passwordReset: { enabled: false },
});
return;
}
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: "/mobile",
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-internal-readiness-token", expiresIn: 900, mustChangePassword: false })),
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, buildMe());
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-internal-readiness",
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: [], admin: [], backup: [] },
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, unreadOnly: true, unreadCount: 0, items: [] });
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: "workspace-internal-readiness", persisted: true, profile: "small_team_internal" },
greeting: { 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/attendance/monthly") {
await fulfillJson(route, {
month: "2026-07",
summary: {
employeeCount: 1,
workDays: 1,
checkinDays: 1,
missingDays: 0,
approvedCorrectionCount: 0,
},
employees: [
{
employee: {
id: "employee-internal-readiness",
employeeNo: "SYN005",
name: "合成员工",
title: "工程师",
departmentId: "department-internal-readiness",
departmentName: "研发部",
},
days: [
{
date: "2026-07-17",
status: "normal",
explanation: "",
firstCheckinAt: "2026-07-17T01:00:00.000Z",
lastCheckinAt: "2026-07-17T10:00:00.000Z",
checkins: [
{
id: "checkin-internal-readiness",
checkinAt: "2026-07-17T01:00:00.000Z",
checkinDate: "2026-07-17",
checkinType: "on_duty",
result: "normal",
exceptionType: null,
sourceType: "wecom",
},
],
corrections: [],
leaveRequests: [],
},
],
},
],
});
return;
}
if (pathName === "/api/attendance-corrections") {
await fulfillJson(route, { page: 1, pageSize: 50, total: 0, items: [] });
return;
}
if (pathName === "/api/wecom/approval-archive") {
const items = state.archiveMode === "record" ? [archiveItem()] : [];
await fulfillJson(route, {
scope: "self",
requestedScope: "self",
canViewAll: false,
page: 1,
pageSize: 50,
total: items.length,
status: "OK",
items,
});
return;
}
if (pathName === "/api/wecom/approval-archive/archive-internal-readiness") {
await fulfillJson(route, { item: archiveDetail() });
return;
}
await fulfillJson(route, { message: `unhandled synthetic endpoint: ${pathName}` }, 404);
});
}
async function login(page: Page) {
await page.goto("/login");
const identifier = page.getByTestId("login-identifier");
await expect(identifier).toBeVisible();
await identifier.fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticPassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/(?:portal|mobile)$/);
}
async function newWecomPage(browser: Browser, state: MockState) {
const context = await browser.newContext({
viewport: { width: 375, height: 812 },
isMobile: true,
hasTouch: true,
userAgent: WECOM_UA,
locale: "zh-CN",
serviceWorkers: "block",
});
const page = await context.newPage();
await mockApis(page, state);
await login(page);
return { context, page };
}
async function expectNarrowPageMetrics(page: Page) {
const metrics = await page.evaluate(() => {
const scrollContainer = document.querySelector<HTMLElement>("main.app-shell-main > div.flex-1");
const bottomNav = document.querySelector<HTMLElement>("[data-testid='mobile-bottom-nav']");
const navHeight = bottomNav?.getBoundingClientRect().height ?? 0;
const paddingBottom = scrollContainer ? Number.parseFloat(window.getComputedStyle(scrollContainer).paddingBottom) : 0;
return {
width: window.innerWidth,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
navHeight,
paddingBottom,
};
});
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
expect(metrics.navHeight).toBeGreaterThan(0);
expect(metrics.paddingBottom).toBeGreaterThan(metrics.navHeight);
}
test.describe("五人团队本地候选移动证据", () => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成数据,不访问公网");
test("/mobile/attendance 在 375x812 下直达考勤且不再自循环", async ({ browser }) => {
const state: MockState = { archiveMode: "empty" };
const mobile = await newWecomPage(browser, state);
try {
await mobile.page.goto("/mobile/attendance");
await expect(mobile.page).toHaveURL(/\/mobile\/attendance$/);
await expect(mobile.page.getByRole("heading", { level: 1, name: "考勤" })).toBeVisible();
await expect(mobile.page.getByText("企业微信主办", { exact: true }).first()).toBeVisible();
await expect(mobile.page.getByText("合成员工", { exact: true }).first()).toBeVisible();
await expectNarrowPageMetrics(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("mobile-attendance-375x812.png"), fullPage: true });
} finally {
await mobile.context.close();
}
});
test("/mobile/wecom-approval-archive 覆盖空态、有记录、详情和长字段", async ({ browser }) => {
const state: MockState = { archiveMode: "empty" };
const mobile = await newWecomPage(browser, state);
try {
await mobile.page.goto("/mobile/wecom-approval-archive");
await expect(mobile.page).toHaveURL(/\/mobile\/wecom-approval-archive$/);
await expect(mobile.page.getByRole("heading", { level: 1, name: "企微审批历史" })).toBeVisible();
await expect(mobile.page.getByText(/暂无已归档的企微审批历史/)).toBeVisible();
await expectNarrowPageMetrics(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("mobile-archive-empty-375x812.png"), fullPage: true });
state.archiveMode = "record";
await mobile.page.reload();
const mobileRecord = mobile.page.getByRole("article").filter({
hasText: "SYNTHETIC-ARCHIVE-PREFIX-WITH-A-DELIBERATELY-LONG-VALUE",
});
await expect(mobileRecord).toBeVisible();
await mobileRecord.getByRole("button", { name: "查看历史" }).click();
const longFieldName = mobile.page.getByText(
"这是一个用于验证移动归档长字段名称能够安全换行而不会撑破页面的合成字段",
{ exact: true },
);
const restrictedPlaceholder = mobile.page.getByText("受限字段", { exact: true });
await longFieldName.scrollIntoViewIfNeeded();
await expect(longFieldName).toBeVisible();
await restrictedPlaceholder.scrollIntoViewIfNeeded();
await expect(restrictedPlaceholder).toBeVisible();
await expectNarrowPageMetrics(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("mobile-archive-detail-375x812.png"), fullPage: true });
} finally {
await mobile.context.close();
}
});
test("/wecom-approval-archive 桌面路由保持只读历史入口", async ({ page }) => {
const state: MockState = { archiveMode: "record" };
await mockApis(page, state);
await login(page);
await page.setViewportSize({ width: 1024, height: 768 });
await page.goto("/wecom-approval-archive");
await expect(page).toHaveURL(/\/wecom-approval-archive$/);
await expect(page.getByRole("heading", { level: 1, name: "企微审批历史" })).toBeVisible();
await expect(page.getByRole("button", { name: "查看历史" })).toBeVisible();
await expect(page.getByRole("button", { name: /同意|驳回|撤销|终止/ })).toHaveCount(0);
await page.screenshot({ path: screenshotPath("desktop-archive-1024x768.png"), fullPage: true });
});
});
@@ -0,0 +1,538 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
test.use({ serviceWorkers: "block" });
export const LOCAL_CANDIDATE_MANIFEST_DRIVEN_WITNESSES = true;
type RoleFixture = "employee" | "manager" | "ceo" | "finance" | "system_admin" | "admin";
type LocalCandidateFlow = {
bundle: string;
witnessRoute: string;
witnessTag: string;
expectedH1: string;
roleFixture: RoleFixture;
primaryAction: {
allowed: string;
forbidden: string;
};
witnessSpec: string;
};
type RouteManifest = {
localCandidateFlows: LocalCandidateFlow[];
};
type SyntheticRole = {
roleCodes: string[];
name: string;
employeeNo: string;
};
type MockState = {
sessionActive: boolean;
loginCalls: number;
logoutCalls: number;
meRequests: number;
unhandledApiCount: number;
};
type BrowserErrorState = {
pageErrorCount: number;
consoleErrorCount: number;
};
const MANIFEST_PATH = path.resolve(process.cwd(), "../scripts/qa/route-manifest.json");
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as RouteManifest;
const localCandidateFlows = manifest.localCandidateFlows;
const ROLE_FIXTURES: Record<RoleFixture, SyntheticRole> = {
employee: { roleCodes: ["employee"], name: "合成员工", employeeNo: "SYN-LC-001" },
manager: { roleCodes: ["employee", "manager"], name: "合成经理", employeeNo: "SYN-LC-002" },
ceo: { roleCodes: ["employee", "ceo"], name: "合成负责人", employeeNo: "SYN-LC-003" },
finance: { roleCodes: ["employee", "finance"], name: "合成财务", employeeNo: "SYN-LC-004" },
system_admin: { roleCodes: ["employee", "system_admin"], name: "合成系统管理员", employeeNo: "SYN-LC-005" },
admin: { roleCodes: ["employee", "admin"], name: "合成应急管理员", employeeNo: "SYN-LC-006" },
};
const CONSOLE_ERROR_ALLOWLIST = [/^ResizeObserver loop/];
function envelope(data: unknown) {
return { requestId: "synthetic-local-candidate", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200, headers: Record<string, string> = {}) {
await route.fulfill({
status,
contentType: "application/json",
headers,
body: JSON.stringify(envelope(data)),
});
}
async function fulfillAuthError(route: Route) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({
requestId: "synthetic-local-candidate",
error: { code: "AUTH_001", message: "会话已失效" },
}),
});
}
function buildMe(role: SyntheticRole) {
return {
id: `synthetic-${role.employeeNo.toLowerCase()}`,
employeeNo: role.employeeNo,
name: role.name,
phone: "00000000000",
email: `${role.employeeNo.toLowerCase()}@example.test`,
departmentId: "synthetic-department",
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: role.roleCodes,
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: false,
hasAvatar: false,
hasIdNumber: false,
score: 40,
},
wecomBinding: null,
};
}
function buildPortalHome(role: SyntheticRole) {
return {
workspace: { id: "synthetic-workspace", persisted: true, profile: "small_team_internal" },
greeting: { name: role.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,
financeSummary: null,
adoptionSummary: null,
recentActivities: [],
teamStatusBoard: [],
};
}
function buildWorkflowTask(role: SyntheticRole) {
return {
id: "synthetic-workflow-task",
instanceId: "synthetic-workflow-instance",
stepId: "manager-review",
stepName: "经理复核",
assigneeId: `synthetic-${role.employeeNo.toLowerCase()}`,
assigneeRole: "manager",
status: "pending",
outcome: null,
createdAt: "2026-07-18T08:00:00.000Z",
completedAt: null,
workflowInstance: {
entityType: "reimbursement",
entityId: "synthetic-reimbursement",
currentStatus: "reviewing",
},
businessSummary: {
reference: "SYN-RB-001",
title: "合成报销复核",
applicantName: "合成申请人",
amount: "100.00",
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
};
}
function buildAttendanceMonthly(role: SyntheticRole) {
return {
month: "2026-07",
summary: {
employeeCount: 1,
workDays: 1,
checkinDays: 1,
missingDays: 0,
approvedCorrectionCount: 0,
},
employees: [
{
employee: {
id: `synthetic-${role.employeeNo.toLowerCase()}`,
employeeNo: role.employeeNo,
name: role.name,
title: "合成岗位",
departmentId: "synthetic-department",
departmentName: "合成部门",
},
days: [
{
date: "2026-07-18",
status: "normal",
explanation: "",
firstCheckinAt: "2026-07-18T01:00:00.000Z",
lastCheckinAt: "2026-07-18T10:00:00.000Z",
checkins: [],
corrections: [],
leaveRequests: [],
},
],
},
],
};
}
async function installSyntheticApis(page: Page, roleFixture: RoleFixture) {
const role = ROLE_FIXTURES[roleFixture];
const state: MockState = {
sessionActive: false,
loginCalls: 0,
logoutCalls: 0,
meRequests: 0,
unhandledApiCount: 0,
};
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/capabilities") {
await fulfillJson(route, {
emailDelivery: { ready: false, statusReasons: ["synthetic_disabled"] },
emailLoginCode: { enabled: false },
passwordReset: { enabled: false },
});
return;
}
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") {
state.sessionActive = true;
state.loginCalls += 1;
await fulfillJson(
route,
{ accessToken: "synthetic-local-candidate-token", expiresIn: 900, mustChangePassword: false },
200,
{ "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
);
return;
}
if (pathName === "/api/auth/refresh") {
if (!state.sessionActive) {
await fulfillAuthError(route);
return;
}
await fulfillJson(route, { accessToken: "synthetic-local-candidate-token", expiresIn: 900 });
return;
}
if (pathName === "/api/auth/logout") {
state.sessionActive = false;
state.logoutCalls += 1;
await fulfillJson(route, {}, 200, { "set-cookie": "has_session=; Path=/; Max-Age=0" });
return;
}
if (pathName === "/api/me") {
state.meRequests += 1;
await fulfillJson(route, buildMe(role));
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "synthetic-workspace",
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: [], admin: [], backup: [] },
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: 0,
unreadOnly: url.searchParams.get("unreadOnly") === "1",
unreadCount: 0,
items: [],
});
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, buildPortalHome(role));
return;
}
if (pathName === "/api/reimbursement-templates") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/reimbursements/linkable-travel-requests") {
await fulfillJson(route, { ownerEmployeeId: `synthetic-${role.employeeNo.toLowerCase()}`, items: [] });
return;
}
if (pathName === "/api/reimbursements") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, scope: "own", items: [] });
return;
}
if (pathName === "/api/workflow/tasks/summary") {
await fulfillJson(route, {
scope: "mine",
pendingTotal: 1,
overdueTotal: 0,
earliestPendingAt: "2026-07-18T08:00:00.000Z",
averagePendingAgeHours: 1,
byEntityType: [],
byStep: [],
byAssigneeRole: [],
});
return;
}
if (pathName === "/api/workflow/tasks") {
const items = url.searchParams.get("status") === "completed" ? [] : [buildWorkflowTask(role)];
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, items });
return;
}
if (pathName === "/api/invoice-pool") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, ownerScope: "own", items: [] });
return;
}
if (pathName === "/api/master-data/suppliers") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/master-data/legal-entities") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/master-data/cost-centers") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/master-data/document-categories") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/purchase-requests") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, mine: true, items: [] });
return;
}
if (pathName === "/api/projects") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry/expiring") {
await fulfillJson(route, { days: Number(url.searchParams.get("days") ?? 30), total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/payment-batches") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/users") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/departments") {
await fulfillJson(route, { includeArchived: false, items: [], tree: [] });
return;
}
if (pathName === "/api/employees") {
await fulfillJson(route, { page: 1, pageSize: 200, total: 0, items: [] });
return;
}
if (pathName === "/api/attendance/monthly") {
await fulfillJson(route, buildAttendanceMonthly(role));
return;
}
if (pathName === "/api/attendance-corrections") {
await fulfillJson(route, { page: 1, pageSize: 50, total: 0, items: [] });
return;
}
state.unhandledApiCount += 1;
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({
requestId: "synthetic-local-candidate",
error: { code: "NOT_FOUND", message: "合成接口未登记" },
}),
});
});
return { role, state };
}
function installBrowserErrorMonitor(page: Page): BrowserErrorState {
const state: BrowserErrorState = { pageErrorCount: 0, consoleErrorCount: 0 };
page.on("pageerror", () => {
state.pageErrorCount += 1;
});
page.on("console", (message) => {
if (message.type() !== "error") return;
if (CONSOLE_ERROR_ALLOWLIST.some((pattern) => pattern.test(message.text()))) return;
state.consoleErrorCount += 1;
});
return state;
}
async function loginToWitness(page: Page, witnessRoute: string) {
await page.goto(`/login?redirect=${encodeURIComponent(witnessRoute)}`);
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly1!");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL((url) => url.pathname === witnessRoute);
}
async function expectUniqueH1(page: Page, expectedH1: string) {
const heading = page.getByRole("heading", { level: 1, name: expectedH1, exact: true });
await expect(heading).toHaveCount(1);
await expect(heading).toBeVisible();
}
async function expectSyntheticRole(page: Page, role: SyntheticRole, state: MockState, mobile: boolean) {
expect(state.meRequests).toBeGreaterThan(0);
if (!mobile) {
await expect(page.getByText(`${role.name} · ${role.employeeNo}`, { exact: true }).first()).toBeVisible();
}
}
async function expectNamedControlVisible(page: Page, name: string) {
const candidates = [
page.getByRole("button", { name, exact: true }),
page.getByRole("link", { name, exact: true }),
page.getByRole("tab", { name, exact: true }),
page.getByRole("menuitem", { name, exact: true }),
page.getByText(name, { exact: true }),
];
await expect.poll(async () => {
for (const candidate of candidates) {
for (let index = 0; index < await candidate.count(); index += 1) {
if (await candidate.nth(index).isVisible()) return true;
}
}
return false;
}).toBe(true);
}
async function expectPrimaryActionContract(page: Page, flow: LocalCandidateFlow) {
await expectNamedControlVisible(page, flow.primaryAction.allowed);
await expect(page.getByText(flow.primaryAction.forbidden, { exact: true })).toHaveCount(0);
}
async function expectSafeBrowserState(errorState: BrowserErrorState, state: MockState) {
expect(state.unhandledApiCount, "unhandled synthetic API count").toBe(0);
expect(errorState.pageErrorCount, "pageerror count").toBe(0);
expect(errorState.consoleErrorCount, "non-allowlisted console error count").toBe(0);
}
async function runLoginLogoutWitness(
page: Page,
flow: LocalCandidateFlow,
role: SyntheticRole,
state: MockState,
) {
await page.goto(flow.witnessRoute);
await expect(page).toHaveURL((url) => url.pathname === flow.witnessRoute);
await expectUniqueH1(page, flow.expectedH1);
await expectPrimaryActionContract(page, flow);
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly1!");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL((url) => url.pathname === "/portal");
await expectSyntheticRole(page, role, state, false);
await page.getByTestId("topbar-profile-trigger").click();
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
await expect(page).toHaveURL((url) => url.pathname === "/login");
await expectUniqueH1(page, flow.expectedH1);
expect(state.loginCalls).toBe(1);
expect(state.logoutCalls).toBe(1);
expect(state.sessionActive).toBe(false);
expect((await page.context().cookies()).some((cookie) => cookie.name === "has_session" && cookie.value)).toBe(false);
await expect.poll(() => page.evaluate(() => ({
sessionHint: document.cookie.includes("has_session=1"),
offlineSnapshot: window.localStorage.getItem("hyb-oa.auth_snapshot"),
mustChangePassword: window.localStorage.getItem("hyb-oa.must_change_password"),
}))).toEqual({ sessionHint: false, offlineSnapshot: null, mustChangePassword: null });
const meRequestsAfterLogout = state.meRequests;
await page.reload();
await expect(page).toHaveURL((url) => url.pathname === "/login");
await expectUniqueH1(page, flow.expectedH1);
expect(state.meRequests).toBe(meRequestsAfterLogout);
}
test.describe("五人团队本地候选可信 witness", () => {
for (const flow of localCandidateFlows) {
test(flow.witnessTag, async ({ page }) => {
expect(process.env.E2E_MODE).not.toBe("live");
const mobile = flow.witnessRoute.startsWith("/mobile");
if (mobile) {
await page.setViewportSize({ width: 375, height: 812 });
}
const errorState = installBrowserErrorMonitor(page);
const { role, state } = await installSyntheticApis(page, flow.roleFixture);
if (flow.bundle === "login-logout") {
await runLoginLogoutWitness(page, flow, role, state);
} else {
await loginToWitness(page, flow.witnessRoute);
await expect(page).toHaveURL((url) => url.pathname === flow.witnessRoute);
await expectUniqueH1(page, flow.expectedH1);
await expectSyntheticRole(page, role, state, mobile);
await expectPrimaryActionContract(page, flow);
}
await expectSafeBrowserState(errorState, state);
});
}
});
+9 -5
查看文件
@@ -1,5 +1,5 @@
import { test, expect } from "./helpers/fixtures";
import { ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { ADMIN, EMPLOYEE, FINANCE, MANAGER, apiGetData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
@@ -108,6 +108,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
const paymentDate = new Date().toISOString().slice(0, 10);
const reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`);
const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId;
const batchName = `E2E-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
@@ -118,9 +120,11 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-create-entity-type").selectOption("reimbursement");
await createCard.getByTestId("payment-batch-create-name").fill(batchName);
await createCard.getByTestId("payment-batch-create-entity-ids").fill(reimbursementId);
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate);
await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank");
await createCard.getByTestId("payment-batch-create-voucher-prefix").fill("E2EPB");
@@ -132,7 +136,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
}
await logout(page, FINANCE.name);
await login(page, FINANCE_MGR);
await login(page, MANAGER);
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /待付款/ }).click();
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
@@ -143,7 +147,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await page.getByRole("button", { name: "提交复核" }).click();
await expect(pendingRow).toContainText("复核 1/1");
await logout(page, FINANCE_MGR.name);
await logout(page, MANAGER.name);
await login(page, FINANCE);
await page.goto(`/payment-batches?id=${batchId}`);
const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
@@ -162,7 +162,8 @@ test("ASSET1D 公网资产试录:FA/EA-SYS/EA-REPO/EA-BKP", async ({ page }) =
await login(page, admin);
await page.goto("/assets");
await page.getByRole("button", { name: "资产分类" }).click();
await page.locator(".page-header-shell").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
const category = await createTrialCategory(page, suffix);
@@ -1174,16 +1174,19 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
}
await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled();
await expect(createCard.getByRole("button", { name: "创建批次" })).toBeDisabled();
const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches";
});
await createCard.locator("select").nth(0).selectOption("reimbursement");
await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName);
await createCard.getByPlaceholder("输入报销或差旅待付款单据 ID,多个用逗号或换行分隔").fill(state.reimbursementId);
const reimbursementCode = state.reimbursementCode ?? state.reimbursementId;
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10));
await createCard.locator("select").nth(1).selectOption("bank");
await createCard.locator("select").nth(0).selectOption("bank");
await createCard.getByPlaceholder("例如 PBX").fill(`${trialrunSafePrefix}-${marker.slice(-6)}`);
await createCard.getByRole("button", { name: "创建批次" }).click();
const createBatchResponse = await createBatchResponsePromise;
@@ -0,0 +1,430 @@
import { expect, test, type Page, type Route } from "@playwright/test";
test.use({ serviceWorkers: "block" });
type SyntheticActor = {
id: string;
employeeNo: string;
name: string;
email: string;
roleCodes: string[];
};
type SyntheticContract = {
id: string;
contractNo: string;
projectId: string | null;
contractType: "purchase" | "sales" | "service" | "framework" | "other";
counterpartyName: string;
summary: string;
amount: string | null;
currency: string;
signedDate: string | null;
effectiveDate: string | null;
expiryDate: string | null;
confidentialityLevel: "public" | "internal" | "confidential" | "restricted";
archiveLocation: string;
offlineArchiveNo: string | null;
ownerEmployeeId: string;
status: "draft" | "active" | "expired" | "archived";
persistedStatus: "draft" | "active" | "archived";
createdAt: string | null;
updatedAt: string | null;
owner: { id: string; employeeNo: string; name: string } | null;
project: { id: string; projectNo: string; name: string } | null;
};
type MockScenario = {
paymentCandidates?: Array<{
entityId: string;
entityType: "reimbursement";
businessNo: string;
employee: { id: string; employeeNo: string; name: string };
amount: string;
currencyCode: "CNY";
businessDate: string | null;
summary: string | null;
}>;
contracts?: SyntheticContract[];
onCreatePaymentBatch?: (payload: unknown) => void;
};
const REVIEWER_ACTOR: SyntheticActor = {
id: "employee-payment-reviewer",
employeeNo: "SYN-REVIEWER",
name: "合成付款复核人",
email: "[email protected]",
roleCodes: ["manager"],
};
const FINANCE_ACTOR: SyntheticActor = {
id: "employee-payment-finance",
employeeNo: "SYN-FINANCE",
name: "合成财务人员",
email: "[email protected]",
roleCodes: ["finance"],
};
const CONTRACT_READER_ACTOR: SyntheticActor = {
id: "employee-contract-reader",
employeeNo: "SYN-CONTRACT-READER",
name: "合成合同只读人员",
email: "[email protected]",
roleCodes: ["employee"],
};
const NO_INVOICE_UPLOAD_ACTOR: SyntheticActor = {
id: "employee-no-invoice-upload",
employeeNo: "SYN-NO-INVOICE-UPLOAD",
name: "合成无票据上传人员",
email: "[email protected]",
roleCodes: ["hr"],
};
const PAYMENT_CANDIDATE_ENTITY_ID = "11111111-1111-4111-8111-111111111111";
const CREATED_PAYMENT_BATCH_ID = "22222222-2222-4222-8222-222222222222";
const PAYMENT_CANDIDATE = {
entityId: PAYMENT_CANDIDATE_ENTITY_ID,
entityType: "reimbursement" as const,
businessNo: "BX-SYN-20260718-01",
employee: {
id: "employee-payment-candidate",
employeeNo: "SYN-PAYEE",
name: "合成付款员工",
},
amount: "1280.50",
currencyCode: "CNY" as const,
businessDate: "2026-07-18",
summary: "合成已审批报销",
};
const CONTRACT_ITEM: SyntheticContract = {
id: "contract-synthetic-readonly",
contractNo: "HT-SYN-READ-001",
projectId: "project-synthetic-contract",
contractType: "service",
counterpartyName: "合成合作方",
summary: "合成只读合同",
amount: "8000.00",
currency: "CNY",
signedDate: "2026-07-01",
effectiveDate: "2026-07-01",
expiryDate: "2027-06-30",
confidentialityLevel: "internal",
archiveLocation: "受控线下归档",
offlineArchiveNo: "SYN-ARCHIVE-001",
ownerEmployeeId: CONTRACT_READER_ACTOR.id,
status: "active",
persistedStatus: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
owner: {
id: CONTRACT_READER_ACTOR.id,
employeeNo: CONTRACT_READER_ACTOR.employeeNo,
name: CONTRACT_READER_ACTOR.name,
},
project: {
id: "project-synthetic-contract",
projectNo: "XM-SYN-001",
name: "合成合同项目",
},
};
function envelope(data: unknown) {
return { requestId: "mock-payment-reviewer-permissions", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function capturePageErrors(page: Page) {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
return errors;
}
function expectNoPageErrors(errors: string[]) {
expect(errors, "页面不应产生未捕获异常").toEqual([]);
}
async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: MockScenario = {}) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const pathName = new URL(request.url()).pathname;
if (pathName === "/api/auth/capabilities") {
await fulfillJson(route, {
emailDelivery: { ready: false },
emailLoginCode: { enabled: false },
passwordReset: { enabled: false },
});
return;
}
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-token-${actor.employeeNo}`,
expiresIn: 900,
mustChangePassword: false,
})),
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, {
...actor,
phone: null,
departmentId: `department-${actor.employeeNo}`,
directManagerId: null,
status: "active",
notificationPushEnabled: true,
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: false,
hasAvatar: false,
hasIdNumber: false,
score: 40,
},
wecomBinding: null,
});
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: `workspace-${actor.employeeNo}`,
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: [], admin: [], backup: [] },
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 0,
unreadOnly: false,
unreadCount: 0,
items: [],
});
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: `workspace-${actor.employeeNo}`, persisted: true, profile: "small_team_internal" },
greeting: { name: actor.name },
cards: [],
pendingTasks: { count: 0, href: "/workflow/tasks" },
myApplications: [],
riskSummary: null,
announcements: [],
quickLinks: [],
recentActivities: [],
teamStatusBoard: [],
});
return;
}
if (pathName === "/api/payment-batches/candidates") {
const items = scenario.paymentCandidates ?? [];
await fulfillJson(route, { items, page: 1, pageSize: 50, total: items.length });
return;
}
if (pathName === "/api/payment-batches" && request.method() === "POST") {
scenario.onCreatePaymentBatch?.(request.postDataJSON());
await fulfillJson(route, { item: { id: CREATED_PAYMENT_BATCH_ID } });
return;
}
if (pathName === "/api/payment-batches") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/risk-flags") {
await fulfillJson(route, { items: [] });
return;
}
if (pathName === "/api/ap-operations") {
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") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry/expiring") {
await fulfillJson(route, { days: 30, total: 0, items: [] });
return;
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: scenario.contracts ?? [] });
return;
}
if (pathName === "/api/projects") {
await fulfillJson(route, { items: [] });
return;
}
await fulfillJson(route, { message: "unhandled synthetic endpoint" }, 404);
});
}
async function login(page: Page, actor: SyntheticActor) {
await page.goto("/login");
await page.getByTestId("login-identifier").fill(actor.email);
await page.getByTestId("login-password").fill("SyntheticPassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/(?:portal|mobile)$/);
}
test("只有付款复核职责的经理可看应付账款但看不到导出", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, REVIEWER_ACTOR);
await login(page, REVIEWER_ACTOR);
await page.goto("/ap-operations");
await expect(page).toHaveURL(/\/ap-operations$/);
await expect(page.getByRole("heading", { level: 1, name: "应付账款", exact: true })).toBeVisible();
await expect(page.getByTestId("ap-export")).toHaveCount(0);
expectNoPageErrors(pageErrors);
});
test("只有付款复核职责的经理看不到新建批次", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, REVIEWER_ACTOR);
await login(page, REVIEWER_ACTOR);
await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches$/);
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "新建批次", exact: true })).toHaveCount(0);
expectNoPageErrors(pageErrors);
});
test("合同只读人员看不到新增编辑续约和归档动作", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] });
await login(page, CONTRACT_READER_ACTOR);
await page.goto("/contracts-registry");
await expect(page).toHaveURL(/\/contracts-registry$/);
await expect(page.getByRole("heading", { level: 1, name: "合同", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "新增合同", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
const contractRow = page
.getByTestId("contracts-registry-table")
.getByRole("row")
.filter({ hasText: CONTRACT_ITEM.contractNo });
await expect(contractRow).toBeVisible();
await contractRow.getByRole("button", { name: "更多", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "查看项目", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "续约", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "归档", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/contracts-registry$/);
expectNoPageErrors(pageErrors);
});
test("无票据上传权限的兼容角色直达票据页时失败关闭", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
await mockSyntheticApis(page, NO_INVOICE_UPLOAD_ACTOR);
await login(page, NO_INVOICE_UPLOAD_ACTOR);
await page.goto("/invoice-pool");
await expect(page).toHaveURL(/\/403$/);
await expect(page.getByRole("heading", { level: 1, name: "无权限访问", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "上传票据", exact: true })).toHaveCount(0);
expectNoPageErrors(pageErrors);
});
test("财务从业务候选选择报销并使用所选实体创建付款批次", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网");
const pageErrors = capturePageErrors(page);
let createPayload: unknown = null;
await mockSyntheticApis(page, FINANCE_ACTOR, {
paymentCandidates: [PAYMENT_CANDIDATE],
onCreatePaymentBatch: (payload) => {
createPayload = payload;
},
});
await login(page, FINANCE_ACTOR);
await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches$/);
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await page.getByRole("button", { name: "新建批次", exact: true }).click();
const candidateRow = page.getByTestId("payment-batch-candidate-row");
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo);
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.employee.name);
await expect(candidateRow).toContainText(`${PAYMENT_CANDIDATE.amount} CNY`);
await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0);
await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0);
await page.getByTestId("payment-batch-create-name").fill("合成付款批次");
await candidateRow.getByRole("checkbox").check();
await page.getByRole("button", { name: "创建批次", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`));
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByText("付款批次已创建", { exact: true })).toBeVisible();
expect(createPayload).toEqual({
entityType: "reimbursement",
name: "合成付款批次",
entityIds: [PAYMENT_CANDIDATE_ENTITY_ID],
});
expectNoPageErrors(pageErrors);
});
@@ -7,6 +7,10 @@ import { expect, test } from "./helpers/fixtures";
test.use({ serviceWorkers: "block" });
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-detail-mobile-visual-wave3");
const taskOutputDir = path.resolve(
process.cwd(),
"../output/playwright/07-18-small-team-internal-readiness",
);
const visualUser = {
identifier: "[email protected]",
@@ -400,6 +404,18 @@ const contractRegistryItem = {
project: { id: projectDetail.id, projectNo: projectDetail.projectNo, name: projectDetail.name },
};
const secondContractRegistryItem = {
...contractRegistryItem,
id: "contract-wave3-second",
contractNo: "HT-2026-W3-02",
counterpartyName: "上海合成技术服务有限公司",
summary: "用于验证合同记录切换保护的第二条合成台账",
amount: "18000.00",
expiryDate: "2026-07-20",
archiveLocation: "离线档案柜 A-02",
offlineArchiveNo: "OFF-2026-W3-02",
};
const purchaseDetail = {
id: "purchase-wave3",
requestNo: "CG20260527001",
@@ -510,6 +526,11 @@ function screenshotPath(fileName: string) {
return path.join(outputDir, fileName);
}
function taskScreenshotPath(fileName: string) {
mkdirSync(taskOutputDir, { recursive: true });
return path.join(taskOutputDir, fileName);
}
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
@@ -901,7 +922,7 @@ async function mockWave3Apis(page: Page) {
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: [contractRegistryItem] });
await fulfillJson(route, { items: [contractRegistryItem, secondContractRegistryItem] });
return;
}
@@ -1055,6 +1076,7 @@ test.beforeEach(async ({ page }) => {
});
test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements/reim-wave3");
@@ -1099,7 +1121,9 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/contracts-registry?projectId=project-wave3");
await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible();
await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible();
const contractRows = page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row");
await expect(contractRows).toHaveCount(2);
await expect(contractRows.first()).toBeVisible();
await page.locator(".ops-command-bar__actions").getByRole("button", { name: "更多" }).click();
await expect(page.getByRole("menuitem", { name: "导出 CSV" })).toBeVisible();
await page.keyboard.press("Escape");
@@ -1108,7 +1132,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.getByRole("button", { name: "新增合同" }).click();
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
await page.getByRole("button", { name: "关闭" }).click();
await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible();
await expect(contractRows).toHaveCount(2);
await page.getByTestId("contracts-registry-table").getByRole("button", { name: "更多" }).first().click();
await page.getByRole("menuitem", { name: "归档" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
@@ -1117,6 +1141,59 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
});
test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => {
for (const viewport of [
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
]) {
await page.setViewportSize(viewport);
await page.goto("/contracts-registry");
await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible();
const table = page.getByTestId("contracts-registry-table");
await expect(table).toBeVisible();
const scrollRegion = table.locator("xpath=ancestor::*[@data-testid='business-table-scroll']").first();
const metrics = await scrollRegion.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
return {
overflowX: window.getComputedStyle(element).overflowX,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
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("button", { name: "更多" }).first();
await expect(operation).toBeVisible();
await operation.click({ trial: true });
await page.screenshot({
path: taskScreenshotPath(`contracts-registry-${viewport.width}x${viewport.height}.png`),
fullPage: true,
});
}
});
test("合同长表单从记录 A 切换到记录 B 时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/contracts-registry");
const table = page.getByTestId("contracts-registry-table");
const editButtons = table.getByRole("button", { name: "编辑", exact: true });
await editButtons.first().click();
const counterparty = page.getByPlaceholder("合同对方名称");
await counterparty.fill("合成未保存合同相对方");
await editButtons.nth(1).click();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("合同表单尚未保存");
await page.getByTestId("unsaved-changes-continue").click();
await expect(page).toHaveURL(/\/contracts-registry$/);
await expect(counterparty).toHaveValue("合成未保存合同相对方");
await editButtons.nth(1).click();
await page.getByTestId("unsaved-changes-discard").click();
await expect(counterparty).toHaveValue(secondContractRegistryItem.counterpartyName);
});
test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
@@ -7,6 +7,10 @@ import { expect, test } from "./helpers/fixtures";
test.use({ serviceWorkers: "block" });
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-finance-admin-visual-wave4");
const taskOutputDir = path.resolve(
process.cwd(),
"../output/playwright/07-18-small-team-internal-readiness",
);
const visualUser = {
identifier: "[email protected]",
@@ -123,7 +127,7 @@ function paymentBatch(status: "draft" | "reviewed" | "failed", suffix: string) {
completedReviewCount: status === "draft" ? 0 : 1,
nextStep: status === "reviewed" ? "ready_for_execute" : "first_review",
makerCheckerRequired: false,
reviewerPermission: "risk.review.manage",
reviewerPermission: "payment.batch.review",
},
reviewComment: null,
secondaryReviewComment: null,
@@ -999,6 +1003,11 @@ async function capture(page: Page, name: string) {
await page.screenshot({ path: path.join(outputDir, `${name}.png`), fullPage: true });
}
async function captureTaskEvidence(page: Page, name: string) {
mkdirSync(taskOutputDir, { recursive: true });
await page.screenshot({ path: path.join(taskOutputDir, `${name}.png`), fullPage: true });
}
const pages = [
{ path: "/payment-batches", proof: "data-testid=payment-batches-table", screenshot: "01-payment-batches", expectStatusStrip: false },
{ path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
@@ -1058,6 +1067,39 @@ test("PC 财务列表使用明确动作入口", async ({ page }) => {
await expect(page.getByTestId("invoice-export-csv")).toBeVisible();
});
test("/payment-batches 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => {
await mockWave4Apis(page);
await login(page);
for (const viewport of [
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
]) {
await page.setViewportSize(viewport);
await page.goto("/payment-batches");
await expect(page.getByRole("heading", { name: "付款", exact: true })).toBeVisible();
const table = page.getByTestId("payment-batches-table");
await expect(table).toBeVisible();
const scrollRegion = table.locator("xpath=ancestor::*[@data-testid='business-table-scroll']").first();
const metrics = await scrollRegion.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
return {
overflowX: window.getComputedStyle(element).overflowX,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
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("button").last();
await expect(operation).toBeVisible();
await operation.click({ trial: true });
await captureTaskEvidence(page, `payment-batches-${viewport.width}x${viewport.height}`);
}
});
test("PC 票据辅助核验动作可提交并重试", async ({ page }) => {
const failedVerification: MockVerificationRecord = {
...(invoiceListItem.verification as MockVerificationRecord),
@@ -537,7 +537,7 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "主管不得直连财务台账" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [403, 409], note: "主管不得直连付款批次" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [200, 409], note: "主管可读取待复核付款批次;409 表示功能门禁关闭" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/audit-logs", statuses: [403], note: "主管不得查看审计日志" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/budgets", statuses: [200], note: "主管可直连预算" });
@@ -379,4 +379,21 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByText(reimbursementId)).toHaveCount(0);
await expect(page.locator("table:visible")).toHaveCount(0);
});
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-item-edit").click();
const addItem = page.getByTestId("reimbursement-item-add");
await addItem.click();
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("报销表单尚未保存");
await page.getByTestId("unsaved-changes-continue").click();
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}$`));
await expect(page.getByTestId("reimbursement-item-editor")).toContainText("编辑明细");
await addItem.click();
await page.getByTestId("unsaved-changes-discard").click();
await expect(page.getByTestId("reimbursement-item-editor")).toContainText("新增明细");
});
});
@@ -15,12 +15,21 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
}
const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103");
const taskScreenshotDir = path.resolve(
process.cwd(),
"../output/playwright/07-18-small-team-internal-readiness",
);
function screenshotPath(fileName: string) {
mkdirSync(screenshotDir, { recursive: true });
return path.join(screenshotDir, fileName);
}
function taskScreenshotPath(fileName: string) {
mkdirSync(taskScreenshotDir, { recursive: true });
return path.join(taskScreenshotDir, fileName);
}
const me = {
id: "employee-r103",
employeeNo: "HYD000103",
@@ -45,6 +54,8 @@ const me = {
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(
@@ -210,7 +221,81 @@ function reimbursementForBind() {
};
}
async function mockApis(page: Page) {
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());
@@ -248,7 +333,7 @@ async function mockApis(page: Page) {
}
if (pathName === "/api/me") {
await fulfillJson(route, me);
await fulfillJson(route, { ...me, roleCodes: options.roleCodes ?? me.roleCodes });
return;
}
@@ -270,7 +355,10 @@ async function mockApis(page: Page) {
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: [],
@@ -341,7 +429,84 @@ async function mockApis(page: Page) {
return;
}
if (pathName === `/api/purchase-requests/${purchaseId}`) {
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/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;
}
@@ -428,12 +593,107 @@ async function login(page: Page) {
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: taskScreenshotPath(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 }) => {
await mockApis(page);
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");
@@ -519,4 +779,116 @@ test.describe("R103 cross page UI consistency", () => {
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?id=${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\\?id=${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?id=${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 });
await page.goto("/assets");
await page.getByTestId("asset-create").click();
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$/);
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();
});
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\\?id=${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\\?id=${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\\?id=${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\\?id=${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();
// 详情查询参数由列表页使用 replace 写入,因此它的真实上一项是进入采购页前的工作台。
await expect(page).toHaveURL(/\/portal$/);
});
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}`),
});
}
}
});
});
@@ -112,9 +112,11 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-create-entity-type").selectOption("reimbursement");
await createCard.getByTestId("payment-batch-create-name").fill(batchName);
await createCard.getByTestId("payment-batch-create-entity-ids").fill(reimbursementId);
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate);
await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank");
await createCard.getByTestId("payment-batch-create-voucher-prefix").fill("R78PB");
@@ -135,7 +137,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await logout(page, FINANCE.name);
await login(page, FINANCE_MGR);
await login(page, MANAGER);
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /待付款/ }).click();
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
@@ -146,7 +148,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await page.getByRole("button", { name: "提交复核" }).click();
await expect(pendingRow).toContainText("复核 1/1");
await logout(page, FINANCE_MGR.name);
await logout(page, MANAGER.name);
await login(page, FINANCE);
await page.goto(`/payment-batches?id=${batchId}`);
@@ -53,6 +53,8 @@ const me = {
const reimbursementId = "reimbursement-action-confirm";
const paymentBatchId = "payment-batch-action-confirm";
const reimbursementAttachmentId = "reimbursement-attachment-action-confirm";
const paymentReceiptId = "payment-receipt-action-confirm";
function reimbursementListItem() {
return {
@@ -78,6 +80,50 @@ function reimbursementListItem() {
};
}
function reimbursementDetail() {
return {
...reimbursementListItem(),
submitterId: me.id,
ownerId: me.id,
departmentId: "department-action-confirm",
department: { id: "department-action-confirm", name: "合成部门" },
submittedAt: null,
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
owner: { id: me.id, employee_no: me.employeeNo, name: me.name },
submitter: { id: me.id, employee_no: me.employeeNo, name: me.name },
items: [],
invoices: [],
attachments: [
{
id: reimbursementAttachmentId,
originalName: "合成报销敏感附件.pdf",
mimeType: "application/pdf",
fileSize: 1024,
fileSha256: "synthetic-attachment-hash",
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "supporting_document",
downloadPolicy: "reason_required",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
createdAt: "2026-05-15T08:05:00.000Z",
},
],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
};
}
function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
return {
id: paymentBatchId,
@@ -96,7 +142,7 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
completedReviewCount: status === "draft" ? 0 : 1,
nextStep: status === "draft" ? "first_review" : "ready_for_execute",
makerCheckerRequired: false,
reviewerPermission: "risk.review.manage",
reviewerPermission: "payment.batch.review",
},
reviewComment: null,
secondaryReviewComment: null,
@@ -111,6 +157,7 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
executedBy: null,
cancelledAt: null,
cancelledBy: null,
cancelledReason: null,
items: [
{
id: "payment-batch-item-action-confirm",
@@ -133,11 +180,20 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
};
}
async function mockSystemActionApis(page: Page) {
async function mockSystemActionApis(
page: Page,
options: { batchStatus?: "draft" | "reviewed"; meOverride?: Partial<typeof me> } = {},
) {
const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed";
const calls = {
deleteReimbursement: 0,
deleteReimbursementAttachment: 0,
exportReimbursements: 0,
executePaymentBatch: 0,
voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[],
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
@@ -191,7 +247,7 @@ async function mockSystemActionApis(page: Page) {
}
if (path === "/api/me") {
await fulfillJson(route, me);
await fulfillJson(route, currentMe);
return;
}
@@ -220,6 +276,22 @@ async function mockSystemActionApis(page: Page) {
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
await fulfillJson(route, reimbursementDetail());
return;
}
if (
path === `/api/reimbursements/${reimbursementId}/attachments/${reimbursementAttachmentId}` &&
request.method() === "DELETE"
) {
calls.deleteReimbursementAttachment += 1;
const body = request.postDataJSON() as { reason?: string };
calls.attachmentDeleteReasons.push(body.reason ?? "");
await fulfillJson(route, { attachmentId: reimbursementAttachmentId, outcome: "deleted" });
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") {
calls.deleteReimbursement += 1;
await fulfillJson(route, { id: reimbursementId, deleted: true });
@@ -250,7 +322,45 @@ async function mockSystemActionApis(page: Page) {
}
if (path === "/api/payment-batches" && request.method() === "GET") {
await fulfillJson(route, { items: [paymentBatch("reviewed")] });
await fulfillJson(route, { items: [paymentBatch(batchStatus)] });
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") {
await fulfillJson(route, {
items: [
{
id: paymentReceiptId,
originalName: "合成银行回单.pdf",
mimeType: "application/pdf",
fileSize: 2048,
fileSha256: "synthetic-receipt-hash",
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "payment_receipt",
downloadPolicy: "reason_required",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
createdAt: "2026-05-15T08:20:00.000Z",
},
],
});
return;
}
if (
path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/void` &&
request.method() === "POST"
) {
calls.voidPaymentReceipt += 1;
const body = request.postDataJSON() as { reason?: string };
calls.receiptVoidReasons.push(body.reason ?? "");
await fulfillJson(route, { attachmentId: paymentReceiptId, outcome: "voided", voidedAt: "2026-05-15T08:30:00.000Z" });
return;
}
@@ -320,4 +430,69 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.executePaymentBatch).toBe(1);
});
test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page);
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const attachment = page.locator(".detail-evidence-card").filter({ hasText: "合成报销敏感附件.pdf" });
await expect(attachment).toBeVisible();
await attachment.getByRole("button", { name: "删除" }).click();
const dialog = page.getByTestId("attachment-delete-confirm-dialog");
await expect(dialog).toContainText("合成报销敏感附件.pdf");
await expect(dialog).toContainText("无法在 OA 内恢复");
expect(calls.deleteReimbursementAttachment).toBe(0);
await page.getByTestId("attachment-delete-confirm-submit").click();
await expect(dialog).toContainText("请填写至少 2 个字的删除理由");
expect(calls.deleteReimbursementAttachment).toBe(0);
await dialog.getByRole("button", { name: "取消" }).click();
expect(calls.deleteReimbursementAttachment).toBe(0);
await attachment.getByRole("button", { name: "删除" }).click();
await page.getByRole("textbox", { name: "删除理由" }).fill("附件重复上传");
await page.getByTestId("attachment-delete-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursementAttachment).toBe(1);
expect(calls.attachmentDeleteReasons).toEqual(["附件重复上传"]);
});
test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page);
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
await expect(receipt).toBeVisible();
await receipt.getByRole("button", { name: "作废" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("合成银行回单.pdf");
await expect(dialog).toContainText("文件记录会保留并标记为已作废");
await expect(page.getByRole("button", { name: "确认作废" })).toBeDisabled();
expect(calls.voidPaymentReceipt).toBe(0);
await page.getByRole("textbox", { name: "回单作废原因" }).fill("一");
await expect(page.getByRole("button", { name: "确认作废" })).toBeDisabled();
expect(calls.voidPaymentReceipt).toBe(0);
await dialog.getByRole("button", { name: "返回" }).click();
expect(calls.voidPaymentReceipt).toBe(0);
await receipt.getByRole("button", { name: "作废" }).click();
await page.getByRole("textbox", { name: "回单作废原因" }).fill("回单文件上传有误");
await page.getByRole("button", { name: "确认作废" }).click();
await expect.poll(() => calls.voidPaymentReceipt).toBe(1);
expect(calls.receiptVoidReasons).toEqual(["回单文件上传有误"]);
});
test("只有超级管理员职责且未兼任经理或负责人时不显示付款复核", async ({ page }) => {
await mockSystemActionApis(page, {
batchStatus: "draft",
meOverride: { roleCodes: ["admin"] },
});
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
await expect(page.getByRole("button", { name: "复核", exact: true })).toHaveCount(0);
});
});
+130 -3
查看文件
@@ -9,12 +9,18 @@ function envelope(data: unknown) {
function mockCommonAuth(page: import("@playwright/test").Page, options: {
mustChangePassword?: boolean;
meId?: string;
meName?: string;
meEmployeeNo?: string;
permissions?: string[];
roleCodes?: string[];
}) {
const mustChangePassword = options.mustChangePassword === true;
const meId = options.meId ?? "employee-admin-1";
const meName = options.meName ?? "Admin";
const meEmployeeNo = options.meEmployeeNo ?? "HYD000001";
const permissions = options.permissions ?? ["system.users", "system.accounts.manage", "system.roles.manage"];
const roleCodes = options.roleCodes ?? ["admin"];
return Promise.all([
page.route("**/api/auth/sso/providers", async (route) => {
@@ -64,7 +70,7 @@ function mockCommonAuth(page: import("@playwright/test").Page, options: {
contentType: "application/json",
body: JSON.stringify(
envelope({
id: "employee-admin-1",
id: meId,
employeeNo: meEmployeeNo,
name: meName,
phone: "13800000000",
@@ -73,8 +79,8 @@ function mockCommonAuth(page: import("@playwright/test").Page, options: {
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: ["admin"],
permissions: ["system.users"],
roleCodes,
permissions,
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
@@ -135,6 +141,73 @@ function mockCommonAuth(page: import("@playwright/test").Page, options: {
]);
}
type MockUser = {
id: string;
employeeNo: string;
name: string;
phone: string;
email: string;
status: "active" | "archived";
departmentId: string;
title: string;
roleCodes: string[];
createdAt: string;
updatedAt: string;
};
function buildMockUser(id: string, name: string, roleCodes: string[]): MockUser {
return {
id,
employeeNo: `MOCK-${id}`,
name,
phone: "00000000000",
email: `${id}@example.test`,
status: "active",
departmentId: "dept-1",
title: "员工",
roleCodes,
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
};
}
async function mockUserManagementData(
page: import("@playwright/test").Page,
items: MockUser[],
) {
await Promise.all([
page.route((url) => url.pathname === "/api/users", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({ page: 1, pageSize: 20, total: items.length, items })),
});
}),
page.route((url) => url.pathname === "/api/departments", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({ includeArchived: false, items: [], tree: [] })),
});
}),
page.route((url) => url.pathname === "/api/employees", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({ page: 1, pageSize: 200, total: 0, items: [] })),
});
}),
]);
}
async function loginToUserManagement(page: import("@playwright/test").Page) {
await page.goto("/login?redirect=%2Fsystem%2Fusers");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticPassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/system\/users/);
}
test("E2E 用户开通:新建用户时由系统生成工号和一次性口令", async ({ page }) => {
let createPayload: Record<string, unknown> | null = null;
@@ -234,6 +307,60 @@ test("E2E 用户开通:新建用户时由系统生成工号和一次性口令"
await expect(page.getByTestId("users-reset-result")).toContainText("ResetA1abc123def456");
});
test("E2E 账号保护:OA 系统管理员只能操作普通账号", async ({ page }) => {
const currentId = "system-admin-current";
const ordinary = buildMockUser("ordinary-user", "普通员工", ["employee"]);
const protectedAdmin = buildMockUser("protected-admin", "应急账号甲", ["employee", "admin"]);
const protectedSystemAdmin = buildMockUser("protected-system-admin", "运维账号乙", ["employee", "system_admin"]);
const currentSystemAdmin = buildMockUser(currentId, "当前运维账号", ["employee", "system_admin"]);
await mockCommonAuth(page, {
meId: currentId,
meName: "当前运维账号",
roleCodes: ["system_admin"],
permissions: ["system.accounts.manage", "system.roles.manage"],
});
await mockUserManagementData(page, [ordinary, protectedAdmin, protectedSystemAdmin, currentSystemAdmin]);
await loginToUserManagement(page);
const ordinaryRow = page.getByTestId("users-row").filter({ hasText: ordinary.name });
await expect(ordinaryRow.getByTestId("users-toggle")).toHaveCount(1);
await expect(ordinaryRow.getByTestId("users-reset-password")).toHaveCount(1);
await expect(ordinaryRow.getByTestId("users-edit-roles")).toHaveCount(1);
for (const user of [protectedAdmin, protectedSystemAdmin, currentSystemAdmin]) {
const row = page.getByTestId("users-row").filter({ hasText: user.name });
await expect(row.getByTestId("users-toggle")).toHaveCount(0);
await expect(row.getByTestId("users-reset-password")).toHaveCount(0);
await expect(row.getByTestId("users-edit-roles")).toHaveCount(0);
await expect(row.getByTestId("users-protected-account")).toHaveText("受保护账号");
}
});
test("E2E 账号保护:应急超级管理员可操作其他受保护账号但不能停用或改自己的角色", async ({ page }) => {
const currentId = "employee-admin-1";
const otherAdmin = buildMockUser("other-admin", "其他应急账号", ["employee", "admin"]);
const otherSystemAdmin = buildMockUser("other-system-admin", "其他运维账号", ["employee", "system_admin"]);
const currentAdmin = buildMockUser(currentId, "当前应急账号", ["employee", "admin"]);
await mockCommonAuth(page, { meId: currentId, meName: currentAdmin.name });
await mockUserManagementData(page, [otherAdmin, otherSystemAdmin, currentAdmin]);
await loginToUserManagement(page);
for (const user of [otherAdmin, otherSystemAdmin]) {
const row = page.getByTestId("users-row").filter({ hasText: user.name });
await expect(row.getByTestId("users-toggle")).toHaveCount(1);
await expect(row.getByTestId("users-reset-password")).toHaveCount(1);
await expect(row.getByTestId("users-edit-roles")).toHaveCount(1);
await expect(row.getByTestId("users-protected-account")).toHaveCount(0);
}
const currentRow = page.getByTestId("users-row").filter({ hasText: currentAdmin.name });
await expect(currentRow.getByTestId("users-toggle")).toHaveCount(0);
await expect(currentRow.getByTestId("users-edit-roles")).toHaveCount(0);
await expect(currentRow.getByTestId("users-reset-password")).toHaveCount(1);
});
test("E2E 首次登录:一次性口令登录后强制改密", async ({ page }) => {
let changePasswordPayload: Record<string, unknown> | null = null;