文件
hyb-oa/client/tests/e2e/local-candidate-witnesses.spec.ts
T

539 行
17 KiB
TypeScript

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);
});
}
});