878 行
38 KiB
TypeScript
878 行
38 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { EMPLOYEE, login } from "./helpers/auth";
|
|
import { TEST_IDS } from "./helpers/testIds";
|
|
|
|
test.use({ serviceWorkers: "block" });
|
|
|
|
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "王顺利", employeeNo: "HYD000006" };
|
|
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "李研发", employeeNo: "HYD000002" };
|
|
|
|
function envelope(data: unknown) {
|
|
return {
|
|
requestId: "mock-request",
|
|
data,
|
|
};
|
|
}
|
|
|
|
function authCapabilitiesPayload(emailReady = true) {
|
|
return {
|
|
emailDelivery: {
|
|
ready: emailReady,
|
|
statusReasons: emailReady ? [] : ["email_disabled"],
|
|
},
|
|
emailLoginCode: {
|
|
enabled: emailReady,
|
|
},
|
|
passwordReset: {
|
|
enabled: emailReady,
|
|
},
|
|
};
|
|
}
|
|
|
|
function mockPasswordLogin(page: import("@playwright/test").Page, emailReady = true) {
|
|
return Promise.all([
|
|
page.route("**/api/auth/capabilities", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope(authCapabilitiesPayload(emailReady))),
|
|
});
|
|
}),
|
|
page.route("**/api/auth/sso/providers", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope([])),
|
|
});
|
|
}),
|
|
page.route("**/api/auth/wecom/config", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
enabled: false,
|
|
entryEnabled: false,
|
|
oauthEnabled: false,
|
|
corpId: "",
|
|
agentId: "",
|
|
loginUrl: null,
|
|
bindEnabled: false,
|
|
defaultReturnTo: "/portal",
|
|
status: "disabled",
|
|
statusReasons: ["disabled"],
|
|
})),
|
|
});
|
|
}),
|
|
page.route("**/api/auth/login", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
headers: {
|
|
"set-cookie": "has_session=1; Path=/; SameSite=Strict",
|
|
},
|
|
body: JSON.stringify(envelope({
|
|
accessToken: "test-access-token",
|
|
expiresIn: 900,
|
|
mustChangePassword: false,
|
|
})),
|
|
});
|
|
}),
|
|
page.route("**/api/auth/refresh", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
accessToken: "test-access-token",
|
|
expiresIn: 900,
|
|
})),
|
|
});
|
|
}),
|
|
]);
|
|
}
|
|
|
|
async function mockGuestAuthChrome(page: import("@playwright/test").Page, emailReady = true) {
|
|
await Promise.all([
|
|
page.route("**/api/auth/capabilities", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope(authCapabilitiesPayload(emailReady))),
|
|
});
|
|
}),
|
|
page.route("**/api/auth/sso/providers", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope([])),
|
|
});
|
|
}),
|
|
page.route("**/api/auth/wecom/config", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
enabled: false,
|
|
entryEnabled: false,
|
|
oauthEnabled: false,
|
|
corpId: "",
|
|
agentId: "",
|
|
loginUrl: null,
|
|
bindEnabled: false,
|
|
defaultReturnTo: "/portal",
|
|
status: "disabled",
|
|
statusReasons: ["disabled"],
|
|
})),
|
|
});
|
|
}),
|
|
]);
|
|
}
|
|
|
|
function mockNotificationList(page: import("@playwright/test").Page) {
|
|
return page.route((url) => {
|
|
return url.pathname === "/api/notifications";
|
|
}, async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
page: 1,
|
|
pageSize: 1,
|
|
total: 0,
|
|
unreadOnly: true,
|
|
unreadCount: 0,
|
|
items: [],
|
|
})),
|
|
});
|
|
});
|
|
}
|
|
|
|
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
|
|
const employeeNoByName: Record<string, string> = {
|
|
王顺利: "HYD000006",
|
|
李研发: "HYD000002",
|
|
陈工程: "HYD000003",
|
|
周行政: "HYD000008",
|
|
};
|
|
const employeeNo = employeeNoByName[name] ?? "HYD000003";
|
|
return page.route("**/api/me", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
id: "employee-mock-1",
|
|
employeeNo,
|
|
name,
|
|
phone: "13800000000",
|
|
email: `mock-${employeeNo.toLowerCase()}@hyinda.com`,
|
|
departmentId: "dept-1",
|
|
directManagerId: null,
|
|
status: "active",
|
|
notificationPushEnabled: true,
|
|
roleCodes,
|
|
profileCompleteness: {
|
|
hasEmergencyContact: true,
|
|
hasBankAccount: true,
|
|
hasAvatar: false,
|
|
hasIdNumber: false,
|
|
score: 50,
|
|
},
|
|
wecomBinding: null,
|
|
})),
|
|
});
|
|
});
|
|
}
|
|
|
|
function mockNavigation(page: import("@playwright/test").Page, mode: "employee" | "manager") {
|
|
return page.route("**/api/system/navigation", async (route) => {
|
|
const sections = mode === "manager"
|
|
? {
|
|
main: [
|
|
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "workflow", href: "/workflow/tasks", label: "待我审批", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "purchase", href: "/purchase-requests", label: "采购", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "contracts", href: "/contracts-registry", label: "合同", icon: "description", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
],
|
|
admin: [
|
|
{ key: "settings", href: "/system/settings", label: "管理", icon: "settings", placement: "admin", permissionCodes: [], featureFlagKey: null },
|
|
],
|
|
backup: [
|
|
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "backup", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "backup", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "travel", href: "/travel-requests", label: "差旅申请", icon: "flight", placement: "backup", permissionCodes: [], featureFlagKey: null },
|
|
],
|
|
}
|
|
: {
|
|
main: [
|
|
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "purchase", href: "/purchase-requests", label: "采购", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
],
|
|
admin: [],
|
|
backup: [
|
|
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "backup", permissionCodes: [], featureFlagKey: null },
|
|
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "backup", permissionCodes: [], featureFlagKey: null },
|
|
],
|
|
};
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
workspace: {
|
|
id: "workspace-1",
|
|
persisted: true,
|
|
profile: "small_team_internal",
|
|
orgMode: "single_company",
|
|
primaryEntryMode: "password",
|
|
notificationStrategy: "in_app",
|
|
devicePriority: "desktop",
|
|
moduleVisibility: {},
|
|
createdAt: null,
|
|
updatedAt: null,
|
|
updatedBy: null,
|
|
},
|
|
sections,
|
|
recommendedRolePresets: [],
|
|
})),
|
|
});
|
|
});
|
|
}
|
|
|
|
function mockAttendanceToday(page: import("@playwright/test").Page) {
|
|
return page.route("**/api/attendance/me/today", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
workDate: "2026-03-22",
|
|
needClockIn: true,
|
|
exemptionReason: null,
|
|
item: null,
|
|
})),
|
|
});
|
|
});
|
|
}
|
|
|
|
function mockPortalHome(
|
|
page: import("@playwright/test").Page,
|
|
mode: "employee" | "manager",
|
|
options: { failuresBeforeSuccess?: number } = {},
|
|
) {
|
|
let requestCount = 0;
|
|
|
|
return page.route("**/api/portal/home", async (route) => {
|
|
requestCount += 1;
|
|
if (requestCount <= (options.failuresBeforeSuccess ?? 0)) {
|
|
await route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
requestId: "mock-request",
|
|
error: {
|
|
code: "SYS_001",
|
|
message: "GET /api/portal/home failed",
|
|
details: {},
|
|
},
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const data = mode === "manager"
|
|
? {
|
|
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
|
greeting: { name: "李研发" },
|
|
cards: [
|
|
{ key: "pending_approvals", title: "待我审批", value: 3, href: "/workflow/tasks", severity: "warning" },
|
|
{ key: "team_presence", title: "今日团队状态", value: "6/8 在岗", href: "/team", severity: "warning" },
|
|
{ key: "weekly_missing", title: "未提交周报", value: 2, href: "/weekly-reports", severity: "warning" },
|
|
{ key: "weekly_help", title: "需协助员工", value: 1, href: "/weekly-reports", severity: "high" },
|
|
{ key: "people_lifecycle", title: "入/离职待办", value: 1, href: "/people", severity: "warning" },
|
|
{ key: "asset_return_pending", title: "资产待归还", value: 1, href: "/people", severity: "warning" },
|
|
{ key: "project_milestones", title: "逾期里程碑", value: 2, href: "/projects", severity: "warning" },
|
|
{ key: "project_risks", title: "项目风险", value: 1, href: "/projects", severity: "high" },
|
|
],
|
|
pendingTasks: { count: 3, href: "/workflow/tasks" },
|
|
myApplications: [
|
|
{ domain: "reimbursement", count: 1, href: "/reimbursements" },
|
|
{ domain: "leave", count: 0, href: "/leave-requests" },
|
|
],
|
|
riskSummary: { openCount: 1, href: "/ap-operations" },
|
|
announcements: [{ id: "announcement-1", title: "试点公告", summary: "今天统一从工作台进入。", href: null }],
|
|
quickLinks: [{ key: "projects", title: "项目", href: "/projects" }],
|
|
todayPresence: null,
|
|
teamPresence: { date: "2026-03-22", href: "/team", office: 6, remote: 1, out: 0, travel: 1, leave: 1, unspecified: 1, total: 8 },
|
|
weeklyReport: null,
|
|
weeklySummary: { weekStart: "2026-03-16", submittedCount: 6, missingCount: 2, needsHelpCount: 1, href: "/weekly-reports" },
|
|
todoSummary: { openCount: 4, highPriorityCount: 1, href: "/todos" },
|
|
projectSummary: { myCount: 5, overdueMilestonesCount: 2, highPriorityTaskCount: 2, openRiskCount: 1, followUpDueTodayCount: 2, closurePendingProjectCount: 1, href: "/projects" },
|
|
peopleLifecycle: { onboardingCount: 1, offboardingCount: 0, assetReturnPendingCount: 1, href: "/people" },
|
|
adoptionSummary: { activeEmployeeCount: 8, login7dCount: 8, weeklySubmittedCount: 6, weeklyMissingCount: 2, presenceUpdatedCount: 7, presenceMissingCount: 1, announcementCount: 1, login7dRate: 100, weeklySubmissionRate: 75, presenceCoverageRate: 87.5, hints: ["本周仍有 2 人未提交周报"] },
|
|
recentActivities: [
|
|
{ id: "activity-1", type: "reimbursement.submit", actorName: "张三", summary: "张三 提交了报销单", createdAt: "2026-03-22T09:00:00.000Z", href: "/reimbursements/reimbursement-1" },
|
|
{ id: "activity-2", type: "attendance.clock_in", actorName: "李四", summary: "李四 完成了上班打卡", createdAt: "2026-03-22T08:50:00.000Z", href: "/attendance" },
|
|
],
|
|
teamStatusBoard: [
|
|
{ employeeId: "employee-1", name: "张三", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-03-22T00:55:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null },
|
|
{ employeeId: "employee-2", name: "李四", avatarUrl: null, presenceStatus: "leave", attendanceClockIn: null, attendanceClockOut: null, onLeave: true, leaveType: "年假" },
|
|
],
|
|
}
|
|
: {
|
|
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
|
greeting: { name: "陈工程" },
|
|
cards: [
|
|
{ key: "my_todos", title: "我的事项", value: 4, href: "/todos", severity: "warning" },
|
|
{ key: "today_presence", title: "今日状态", value: "在岗", href: "/team", severity: "normal" },
|
|
{ key: "my_weekly_report", title: "本周周报", value: "待填写", href: "/weekly-reports", severity: "warning" },
|
|
{ key: "my_projects", title: "我的项目", value: 2, href: "/projects", severity: "warning" },
|
|
{ key: "my_leave", title: "我的请假", value: 1, href: "/leave-requests", severity: "normal" },
|
|
{ key: "my_reimbursements", title: "我的报销", value: 2, href: "/reimbursements", severity: "normal" },
|
|
{ key: "my_assets", title: "我的资产", value: 1, href: "/assets", severity: "normal" },
|
|
],
|
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
|
myApplications: [
|
|
{ domain: "reimbursement", count: 2, href: "/reimbursements" },
|
|
{ domain: "leave", count: 1, href: "/leave-requests" },
|
|
],
|
|
riskSummary: null,
|
|
announcements: [{ id: "announcement-1", title: "本周安排", summary: "本周统一回填周报。", href: null }],
|
|
quickLinks: [{ key: "weekly", title: "周报", href: "/weekly-reports" }],
|
|
todayPresence: { date: "2026-03-22", status: "office", label: "在岗", note: "今天在公司办公", href: "/team" },
|
|
teamPresence: null,
|
|
weeklyReport: { weekStart: "2026-03-16", status: "draft", needsHelp: false, href: "/weekly-reports" },
|
|
weeklySummary: null,
|
|
todoSummary: { openCount: 4, highPriorityCount: 1, href: "/todos" },
|
|
projectSummary: { myCount: 2, overdueMilestonesCount: 1, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 1, closurePendingProjectCount: 0, href: "/projects" },
|
|
peopleLifecycle: null,
|
|
adoptionSummary: null,
|
|
recentActivities: [
|
|
{ id: "activity-1", type: "leave.submit", actorName: "陈工程", summary: "陈工程 提交了请假单", createdAt: "2026-03-22T09:00:00.000Z", href: "/leave-requests" },
|
|
],
|
|
teamStatusBoard: [
|
|
{ employeeId: "employee-1", name: "陈工程", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-03-22T00:55:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null },
|
|
{ employeeId: "employee-2", name: "同事", avatarUrl: null, presenceStatus: "remote", attendanceClockIn: null, attendanceClockOut: null, onLeave: false, leaveType: null },
|
|
],
|
|
};
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope(data)),
|
|
});
|
|
});
|
|
}
|
|
|
|
test("登录页提供找回密码、密码显隐,PWA 更新不会清空正在输入的内容", async ({ page }) => {
|
|
await mockGuestAuthChrome(page);
|
|
|
|
await page.goto("/login");
|
|
|
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
|
await page.getByTestId("login-identifier").focus();
|
|
await page.keyboard.press("Tab");
|
|
await expect(page.getByTestId("login-password")).toBeFocused();
|
|
await page.getByTestId("login-password").fill("PasswordA1234");
|
|
await page.getByTestId("login-password").focus();
|
|
await page.keyboard.press("Tab");
|
|
await expect(page.getByRole("link", { name: "忘记密码?" })).toBeFocused();
|
|
|
|
await expect(page.getByTestId("login-password")).toHaveAttribute("type", "password");
|
|
const passwordToggle = page.locator('button[aria-controls="login-password"]');
|
|
await expect(passwordToggle).toHaveAttribute("aria-label", "显示密码");
|
|
await expect(passwordToggle).toHaveAttribute("aria-pressed", "false");
|
|
await expect(passwordToggle).toHaveAttribute("tabindex", "-1");
|
|
await passwordToggle.click();
|
|
await expect(passwordToggle).toHaveAttribute("aria-label", "隐藏密码");
|
|
await expect(passwordToggle).toHaveAttribute("aria-pressed", "true");
|
|
await expect(page.getByTestId("login-password")).toHaveAttribute("type", "text");
|
|
await expect(page.getByTestId("login-identifier")).toHaveValue("[email protected]");
|
|
await expect(page.getByTestId("login-password")).toHaveValue("PasswordA1234");
|
|
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-update-ready", {
|
|
detail: {
|
|
applyUpdate: () => {
|
|
window.sessionStorage.setItem("pwa-update-applied", "1");
|
|
},
|
|
},
|
|
}));
|
|
});
|
|
|
|
await expect(page.getByTestId("auth-pwa-update-notice")).toBeVisible();
|
|
await expect(page.getByTestId("login-identifier")).toHaveValue("[email protected]");
|
|
await expect(page.getByTestId("login-password")).toHaveValue("PasswordA1234");
|
|
|
|
await page.getByRole("button", { name: "稍后" }).click();
|
|
await expect(page.getByTestId("auth-pwa-update-notice")).toBeHidden();
|
|
await expect(page.getByTestId("login-identifier")).toHaveValue("[email protected]");
|
|
await expect(page.getByTestId("login-password")).toHaveValue("PasswordA1234");
|
|
|
|
await page.getByRole("link", { name: "忘记密码?" }).click();
|
|
await expect(page).toHaveURL(/\/forgot-password$/);
|
|
await expect(page.getByTestId("auth-layout-container")).toHaveAttribute("data-layout-variant", "default");
|
|
const forgotPasswordLayoutBox = await page.getByTestId("auth-layout-container").boundingBox();
|
|
expect(forgotPasswordLayoutBox).not.toBeNull();
|
|
expect(forgotPasswordLayoutBox!.width).toBeLessThanOrEqual(460);
|
|
});
|
|
|
|
test("邮箱验证码登录可发送验证码并进入工作台", async ({ page }) => {
|
|
const codeRequests: unknown[] = [];
|
|
const verifyPayloads: unknown[] = [];
|
|
|
|
await mockGuestAuthChrome(page);
|
|
await mockMe(page, ["employee"], "陈工程");
|
|
|
|
await page.route("**/api/auth/email-login/code", async (route) => {
|
|
codeRequests.push(route.request().postDataJSON());
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({})),
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/auth/email-login/verify", async (route) => {
|
|
verifyPayloads.push(route.request().postDataJSON());
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
accessToken: "email-code-access-token",
|
|
expiresIn: 900,
|
|
mustChangePassword: false,
|
|
})),
|
|
headers: {
|
|
"set-cookie": "has_session=1; Path=/; SameSite=Strict",
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.goto("/login?redirect=%2Fportal");
|
|
await page.getByTestId("login-mode-email-code").click();
|
|
await expect(page.getByTestId("login-email-code-panel")).toBeVisible();
|
|
|
|
await page.getByTestId("login-email-code-email").fill("[email protected]");
|
|
await page.getByTestId("login-email-code-request").click();
|
|
await expect(page.getByTestId("login-email-code-message")).toContainText("如账号可用");
|
|
expect(codeRequests).toEqual([{ email: "[email protected]" }]);
|
|
|
|
await page.getByTestId("login-email-code").fill("654321");
|
|
await page.getByTestId("login-email-code-submit").click();
|
|
await expect(page).toHaveURL(/\/portal$/);
|
|
expect(verifyPayloads).toEqual([{ email: "[email protected]", code: "654321" }]);
|
|
});
|
|
|
|
test("邮件服务不可用时登录和找回密码禁用验证码发送", async ({ page }) => {
|
|
await mockGuestAuthChrome(page, false);
|
|
|
|
await page.goto("/login");
|
|
await page.getByTestId("login-mode-email-code").click();
|
|
await expect(page.getByTestId("login-email-delivery-status")).toContainText("邮件验证码暂不可用");
|
|
await page.getByTestId("login-email-code-email").fill("[email protected]");
|
|
await expect(page.getByTestId("login-email-code-request")).toBeDisabled();
|
|
|
|
await page.goto("/forgot-password");
|
|
await expect(page.getByTestId("forgot-password-email-delivery-status")).toContainText("邮件验证码暂不可用");
|
|
await page.getByTestId("forgot-password-email").fill("[email protected]");
|
|
await expect(page.getByTestId("forgot-password-request-code")).toBeDisabled();
|
|
});
|
|
|
|
test("找回密码流程使用邮箱验证码重置新密码", async ({ page }) => {
|
|
const requestedEmails: string[] = [];
|
|
const resetPayloads: unknown[] = [];
|
|
|
|
await page.route("**/api/auth/capabilities", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope(authCapabilitiesPayload())),
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/auth/forgot-password", async (route) => {
|
|
const payload = route.request().postDataJSON() as { email?: string };
|
|
requestedEmails.push(String(payload.email ?? ""));
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({})),
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/auth/reset-password", async (route) => {
|
|
resetPayloads.push(route.request().postDataJSON());
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({})),
|
|
});
|
|
});
|
|
|
|
await page.goto("/forgot-password");
|
|
await page.getByTestId("forgot-password-email").fill("[email protected]");
|
|
await page.getByTestId("forgot-password-request-code").click();
|
|
|
|
await expect(page.getByTestId("forgot-password-message")).toContainText("如账号可用");
|
|
expect(requestedEmails).toEqual(["[email protected]"]);
|
|
|
|
await page.getByTestId("forgot-password-code").fill("123456");
|
|
await page.getByTestId("forgot-password-new").fill("NewPassword1234");
|
|
await page.getByTestId("forgot-password-confirm").fill("NewPassword1234");
|
|
|
|
await page.getByTestId("forgot-password-new").focus();
|
|
await page.keyboard.press("Tab");
|
|
await expect(page.getByTestId("forgot-password-confirm")).toBeFocused();
|
|
await page.keyboard.press("Tab");
|
|
await expect(page.getByTestId("forgot-password-submit")).toBeFocused();
|
|
|
|
await page.getByRole("button", { name: "显示密码" }).first().click();
|
|
await expect(page.getByTestId("forgot-password-new")).toHaveAttribute("type", "text");
|
|
await expect(page.getByTestId("forgot-password-new")).toHaveValue("NewPassword1234");
|
|
|
|
await page.getByTestId("forgot-password-confirm").focus();
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
expect(resetPayloads).toEqual([
|
|
{
|
|
email: "[email protected]",
|
|
code: "123456",
|
|
newPassword: "NewPassword1234",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "employee");
|
|
await mockPortalHome(page, "employee");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["employee"], "陈工程");
|
|
|
|
await login(page, EMPLOYEE);
|
|
|
|
await expect(page.getByTestId("portal-work-summary")).toContainText("待处理 7");
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: /我的事项 4 条.*查看事项/ })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
|
|
await expect(page.getByText("当前焦点")).not.toBeVisible();
|
|
await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
|
|
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
|
await expect(page.locator("summary").filter({ hasText: "更多入口" })).not.toBeVisible();
|
|
const portalSectionOrder = await page.getByRole("heading", { name: /今日待处理|常用入口/ }).evaluateAll((headings) =>
|
|
headings.map((heading) => heading.textContent?.trim()),
|
|
);
|
|
expect(portalSectionOrder).toEqual(["今日待处理", "常用入口"]);
|
|
|
|
const todoLink = page.getByRole("link", { name: /我的事项 4 条.*查看事项/ });
|
|
const weeklyLink = page.getByRole("link", { name: /本周周报待填写.*填写周报/ });
|
|
await todoLink.focus();
|
|
await expect(todoLink).toBeFocused();
|
|
await page.keyboard.press("Tab");
|
|
await expect(weeklyLink).toBeFocused();
|
|
});
|
|
|
|
test("Portal 加载失败持久显示自然中文并可重新加载", async ({ page }) => {
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "employee");
|
|
await mockPortalHome(page, "employee", { failuresBeforeSuccess: 2 });
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["employee"], "陈工程");
|
|
|
|
await login(page, EMPLOYEE);
|
|
|
|
const errorRegion = page.getByTestId("portal-load-error");
|
|
await expect(errorRegion).toContainText("服务暂不可用,请稍后重试或联系管理员");
|
|
await expect(errorRegion).not.toContainText("SYS_001");
|
|
await expect(errorRegion).not.toContainText("/api/portal/home");
|
|
|
|
const retryButton = page.getByTestId("portal-load-retry");
|
|
await retryButton.focus();
|
|
await expect(retryButton).toBeFocused();
|
|
await retryButton.click();
|
|
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
|
await expect(errorRegion).toHaveCount(0);
|
|
});
|
|
|
|
test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page }) => {
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "manager");
|
|
await mockPortalHome(page, "manager");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["manager"], "李研发");
|
|
|
|
await login(page, MANAGER);
|
|
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: /待我审批 3 条.*去处理/ })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: /项目风险未关闭.*处理风险/ })).toBeVisible();
|
|
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
|
await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible();
|
|
await expect(page.getByText("试点观测摘要")).not.toBeVisible();
|
|
});
|
|
|
|
async function prepareEmployeePortal(page: import("@playwright/test").Page) {
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "employee");
|
|
await mockPortalHome(page, "employee");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["employee"], "陈工程");
|
|
await login(page, EMPLOYEE);
|
|
}
|
|
|
|
async function triggerVisibleBuildCheck(page: import("@playwright/test").Page, clientBuildSha: string) {
|
|
const readyRequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/ready");
|
|
await page.evaluate((buildSha) => {
|
|
document.documentElement.dataset.buildSha = buildSha;
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
}, clientBuildSha);
|
|
await readyRequest;
|
|
}
|
|
|
|
test("已登录页面回到前台时发现旧构建并提示用户刷新", async ({ page }) => {
|
|
let readyChecks = 0;
|
|
await page.route("**/ready", async (route) => {
|
|
readyChecks += 1;
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({ ok: true, buildSha: "222222222222" })),
|
|
});
|
|
});
|
|
await prepareEmployeePortal(page);
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto("/mobile");
|
|
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
|
|
|
await triggerVisibleBuildCheck(page, "111111111111");
|
|
|
|
const notice = page.getByTestId("app-pwa-update-notice");
|
|
await expect(notice).toBeVisible();
|
|
await expect(notice).toContainText("发现新版本");
|
|
const refreshButton = notice.getByRole("button", { name: "点击刷新" });
|
|
await expect(refreshButton).toBeVisible();
|
|
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
|
|
await expect.poll(() => readyChecks).toBe(1);
|
|
const refreshButtonBox = await refreshButton.boundingBox();
|
|
expect(refreshButtonBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2);
|
|
|
|
await page.setViewportSize({ width: 667, height: 375 });
|
|
await expect(notice).toBeVisible();
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2);
|
|
await Promise.all([
|
|
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
|
refreshButton.click(),
|
|
]);
|
|
await expect(page).toHaveURL(/\/mobile$/);
|
|
});
|
|
|
|
test("已登录页面回到前台时构建一致保持静默", async ({ page }) => {
|
|
await page.route("**/ready", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({ ok: true, buildSha: "333333333333" })),
|
|
});
|
|
});
|
|
await prepareEmployeePortal(page);
|
|
|
|
await triggerVisibleBuildCheck(page, "333333333333");
|
|
|
|
await expect(page.getByTestId("app-pwa-update-notice")).toHaveCount(0);
|
|
});
|
|
|
|
test("已登录页面回到前台时版本检查失败保持静默", async ({ page }) => {
|
|
await page.route("**/ready", async (route) => {
|
|
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
|
|
});
|
|
await prepareEmployeePortal(page);
|
|
|
|
await triggerVisibleBuildCheck(page, "444444444444");
|
|
|
|
await expect(page.getByTestId("app-pwa-update-notice")).toHaveCount(0);
|
|
});
|
|
|
|
test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清单", async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem("hyb_oa_show_welcome", "1");
|
|
window.localStorage.setItem("hyb_oa_onboarding_active", "1");
|
|
window.localStorage.removeItem("hyb_oa_getting_started_dismissed");
|
|
});
|
|
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "employee");
|
|
await mockPortalHome(page, "employee");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["employee"], "陈工程");
|
|
|
|
await login(page, EMPLOYEE);
|
|
|
|
const welcomeDialog = page.getByTestId(TEST_IDS.onboarding.welcomeDialog);
|
|
await expect(welcomeDialog).toBeVisible();
|
|
await expect(welcomeDialog.getByRole("heading", { name: "常用入口" })).toBeVisible();
|
|
await expect(welcomeDialog.getByRole("link")).toHaveCount(2);
|
|
await expect(welcomeDialog.getByRole("link", { name: /待我审批/ })).toHaveCount(0);
|
|
await expect(welcomeDialog.getByRole("link", { name: /报销/ })).toBeVisible();
|
|
await expect(welcomeDialog.getByRole("link", { name: /请假/ })).toBeVisible();
|
|
await expect(welcomeDialog.getByRole("link", { name: /个人设置/ })).not.toBeVisible();
|
|
await expect(welcomeDialog.getByRole("link", { name: /通知中心/ })).not.toBeVisible();
|
|
|
|
await page.getByTestId(TEST_IDS.onboarding.welcomeClose).evaluate((element: HTMLButtonElement) => {
|
|
element.click();
|
|
});
|
|
await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).not.toBeVisible();
|
|
await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).not.toBeVisible();
|
|
await expect(page.getByText("快速上手")).not.toBeVisible();
|
|
await expect(page.getByText("完善个人资料")).not.toBeVisible();
|
|
await expect(page.getByText("了解请假流程")).not.toBeVisible();
|
|
await expect(page.getByText("查看通知中心")).not.toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
|
});
|
|
|
|
test("个人设置默认只展示账号通知和企业微信,不展示权限 code", async ({ page }) => {
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "employee");
|
|
await mockPortalHome(page, "employee");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["employee"], "陈工程");
|
|
|
|
await login(page, EMPLOYEE);
|
|
await page.goto("/settings/profile");
|
|
|
|
await expect(page.getByRole("heading", { name: "个人设置" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "个人资料" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "账号安全" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "通知偏好" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
|
|
await expect(page.getByText("账号详情")).toBeVisible();
|
|
await expect(page.getByRole("link", { name: "查看员工档案" })).toHaveCount(0);
|
|
await expect(page.getByText("审批规则")).not.toBeVisible();
|
|
await expect(page.getByText("功能开关")).not.toBeVisible();
|
|
await expect(page.getByText("主数据")).not.toBeVisible();
|
|
await expect(page.getByText("审计日志")).not.toBeVisible();
|
|
await expect(page.getByText("权限摘要")).not.toBeVisible();
|
|
await expect(page.getByText("expense.create")).not.toBeVisible();
|
|
await expect(page.getByText("leave.submit")).not.toBeVisible();
|
|
});
|
|
|
|
test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录态", async ({ page }) => {
|
|
let refreshAttempts = 0;
|
|
|
|
await page.addInitScript(() => {
|
|
document.cookie = "has_session=1; path=/";
|
|
window.sessionStorage.removeItem("hyb_oa_chunk_reload_attempted");
|
|
});
|
|
|
|
await page.route("**/api/auth/refresh", async (route) => {
|
|
refreshAttempts += 1;
|
|
|
|
if (refreshAttempts === 1) {
|
|
await route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
requestId: "mock-request",
|
|
error: {
|
|
code: "SYS_001",
|
|
message: "transient bootstrap error",
|
|
details: {},
|
|
},
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
accessToken: "bootstrap-token",
|
|
expiresIn: 900,
|
|
})),
|
|
});
|
|
});
|
|
|
|
await mockNavigation(page, "employee");
|
|
await mockPortalHome(page, "employee");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["employee"], "陈工程");
|
|
|
|
await page.goto("/portal");
|
|
|
|
await expect(page).toHaveURL(/\/portal/);
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
|
expect(refreshAttempts).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test("Portal 域管理角色也能看到团队卡点", async ({ page }) => {
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "manager");
|
|
await mockPortalHome(page, "manager");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["administration"], "周行政");
|
|
|
|
await login(page, EMPLOYEE);
|
|
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: /未交周报/ })).toBeVisible();
|
|
});
|
|
|
|
test("Portal 管理提醒正常时不显示空壳", async ({ page }) => {
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "manager");
|
|
await mockPortalHome(page, "manager");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["admin"], "王顺利");
|
|
|
|
await login(page, ADMIN);
|
|
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "管理提醒" })).not.toBeVisible();
|
|
await expect(page.getByText("管理员按需查看")).not.toBeVisible();
|
|
});
|
|
|
|
test("企微 WebView 下默认进入移动工作台", async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(window.navigator, "userAgent", {
|
|
configurable: true,
|
|
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
|
});
|
|
});
|
|
await mockPasswordLogin(page);
|
|
await mockNavigation(page, "employee");
|
|
await mockPortalHome(page, "employee");
|
|
await mockAttendanceToday(page);
|
|
await mockNotificationList(page);
|
|
await mockMe(page, ["employee"], "陈工程");
|
|
|
|
await page.goto("/login");
|
|
await page.getByTestId(TEST_IDS.login.identifier).fill(EMPLOYEE.identifier);
|
|
await page.getByTestId(TEST_IDS.login.password).fill(EMPLOYEE.password);
|
|
await page.getByTestId(TEST_IDS.login.submit).click();
|
|
|
|
await expect(page).toHaveURL(/\/mobile/);
|
|
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
|
});
|