539 行
17 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
});
|