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 = { 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 = {}) { 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("synthetic-user@example.test"); 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("synthetic-user@example.test"); 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); }); } });