553 行
31 KiB
TypeScript
553 行
31 KiB
TypeScript
import { mkdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
|
|
|
import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth";
|
|
import type { PortalHomePayload } from "../../src/api/portal";
|
|
|
|
test.use({ serviceWorkers: "block" });
|
|
|
|
const ADMIN: E2EUser = {
|
|
identifier: "[email protected]",
|
|
password: "AdminPassword1234",
|
|
name: "王顺利",
|
|
employeeNo: "HYD000006",
|
|
};
|
|
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/portal-task-first-redesign");
|
|
|
|
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) {
|
|
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
const body = await page.screenshot({ path: path.join(SCREENSHOT_DIR, name), fullPage: true });
|
|
await testInfo.attach(name, {
|
|
body,
|
|
contentType: "image/png",
|
|
});
|
|
}
|
|
|
|
function envelope<T>(data: T) {
|
|
return { requestId: "visual-portal", data };
|
|
}
|
|
|
|
type PortalMode = "employee" | "admin" | "finance" | "ceo";
|
|
|
|
function portalPayload(mode: PortalMode) {
|
|
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : mode === "ceo" ? "合成复核人" : "王顺利";
|
|
const base = {
|
|
workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" },
|
|
greeting: { name: displayName },
|
|
myApplications: [{ domain: "reimbursement", count: 1, href: "/reimbursements" }],
|
|
riskSummary: null,
|
|
announcements: [],
|
|
quickLinks: [],
|
|
todayPresence: null,
|
|
weeklyReport: null,
|
|
recentActivities: [],
|
|
teamStatusBoard: [],
|
|
};
|
|
|
|
if (mode === "finance") {
|
|
return {
|
|
...base,
|
|
cards: [],
|
|
pendingTasks: { count: 1, href: "/workflow/tasks" },
|
|
teamPresence: null,
|
|
weeklySummary: null,
|
|
todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" },
|
|
projectSummary: null,
|
|
peopleLifecycle: null,
|
|
roleCapabilities: {
|
|
canSubmitApplications: true,
|
|
canApproveTasks: true,
|
|
canUseFinance: true,
|
|
canManageSystem: false,
|
|
canViewTeam: true,
|
|
isAdminOnly: false,
|
|
},
|
|
financeSummary: { pendingBatchCount: 2, failedItemCount: 1, riskCount: 1, paymentBatchEnabled: true, href: "/ap-operations" },
|
|
adoptionSummary: null,
|
|
};
|
|
}
|
|
|
|
if (mode === "admin") {
|
|
return {
|
|
...base,
|
|
cards: [],
|
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
|
teamPresence: { date: "2026-05-29", href: "/team", office: 6, remote: 1, out: 0, travel: 0, leave: 0, unspecified: 1, total: 8 },
|
|
weeklySummary: { weekStart: "2026-05-25", submittedCount: 6, missingCount: 2, needsHelpCount: 0, href: "/weekly-reports" },
|
|
todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" },
|
|
projectSummary: { myCount: 3, overdueMilestonesCount: 1, highPriorityTaskCount: 0, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" },
|
|
peopleLifecycle: { onboardingCount: 0, offboardingCount: 0, assetReturnPendingCount: 0, href: "/people" },
|
|
roleCapabilities: {
|
|
canSubmitApplications: false,
|
|
canApproveTasks: false,
|
|
canUseFinance: false,
|
|
canManageSystem: true,
|
|
canViewTeam: true,
|
|
isAdminOnly: true,
|
|
},
|
|
financeSummary: null,
|
|
adoptionSummary: { activeEmployeeCount: 8, login7dCount: 8, weeklySubmittedCount: 6, weeklyMissingCount: 2, presenceUpdatedCount: 7, presenceMissingCount: 1, announcementCount: 0, login7dRate: 100, weeklySubmissionRate: 75, presenceCoverageRate: 87.5, hints: ["本周仍有 2 人未提交周报"] },
|
|
};
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
cards: [],
|
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
|
teamPresence: null,
|
|
weeklySummary: null,
|
|
todoSummary: { openCount: 2, highPriorityCount: 1, href: "/todos" },
|
|
projectSummary: { myCount: 2, overdueMilestonesCount: 0, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" },
|
|
peopleLifecycle: null,
|
|
roleCapabilities: {
|
|
canSubmitApplications: true,
|
|
canApproveTasks: false,
|
|
canUseFinance: false,
|
|
canManageSystem: false,
|
|
canViewTeam: false,
|
|
isAdminOnly: false,
|
|
},
|
|
financeSummary: null,
|
|
adoptionSummary: null,
|
|
weeklyReport: { weekStart: "2026-05-25", status: "draft", needsHelp: false, href: "/weekly-reports" },
|
|
};
|
|
}
|
|
|
|
async function mockVisualPortal(page: Page, mode: PortalMode, handoffs?: () => PortalHomePayload["businessHandoffs"]) {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.removeItem("hyb_oa_show_welcome");
|
|
window.localStorage.removeItem("hyb_oa_onboarding_active");
|
|
window.localStorage.removeItem("hyb_oa_getting_started_dismissed");
|
|
});
|
|
|
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
|
const pathName = new URL(route.request().url()).pathname;
|
|
|
|
if (pathName === "/api/auth/capabilities") {
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ emailDelivery: { ready: true, statusReasons: [] }, emailLoginCode: { enabled: true }, passwordReset: { enabled: true } })) });
|
|
return;
|
|
}
|
|
if (pathName === "/api/auth/sso/providers") {
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope([])) });
|
|
return;
|
|
}
|
|
if (pathName === "/api/auth/wecom/config") {
|
|
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: [] })) });
|
|
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: "visual-token", expiresIn: 900 })) });
|
|
return;
|
|
}
|
|
if (pathName === "/api/me") {
|
|
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : mode === "ceo" ? "合成复核人" : "王顺利";
|
|
const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : mode === "ceo" ? "SYN-CEO" : "HYD000006";
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
id: `visual-${mode}`,
|
|
employeeNo,
|
|
name: displayName,
|
|
email: `${mode}@hyinda.com`,
|
|
roleCodes: [mode],
|
|
notificationPushEnabled: true,
|
|
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
|
|
wecomBinding: null,
|
|
})),
|
|
});
|
|
return;
|
|
}
|
|
if (pathName === "/api/system/navigation") {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope({
|
|
workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" },
|
|
sections: {
|
|
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 },
|
|
],
|
|
admin: [],
|
|
backup: [],
|
|
},
|
|
recommendedRolePresets: [],
|
|
})),
|
|
});
|
|
return;
|
|
}
|
|
if (pathName === "/api/notifications") {
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] })) });
|
|
return;
|
|
}
|
|
if (pathName === "/api/portal/home") {
|
|
const payload = portalPayload(mode);
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(handoffs ? {
|
|
...payload, businessHandoffs: handoffs(), pendingTasks: { count: 0, href: "/workflow/tasks" },
|
|
todoSummary: null, weeklyReport: null, projectSummary: null,
|
|
financeSummary: mode === "finance" ? { ...payload.financeSummary, pendingBatchCount: 9, failedItemCount: 1, riskCount: 0 } : null,
|
|
} : payload)) });
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({})) });
|
|
});
|
|
}
|
|
|
|
function responseGate() {
|
|
let release!: () => void;
|
|
const pending = new Promise<void>((resolve) => { release = resolve; });
|
|
return { pending, release };
|
|
}
|
|
|
|
test.describe("visual portal", () => {
|
|
for (const mobile of [false, true]) {
|
|
test(`业务交接${mobile ? "手机" : "PC"}显示真实事项并在处理后刷新消失`, async ({ page }, testInfo) => {
|
|
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
|
let items: NonNullable<PortalHomePayload["businessHandoffs"]>["items"] = [
|
|
{ key: "reimbursement-batch:synthetic", kind: "reimbursement_batch", reference: "BX-SYN", title: "报销待组建付款批次", nextStep: "核对付款资料后组批", ownerLabel: "财务", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" },
|
|
{ key: "payment-execute:synthetic", kind: "payment_execute", reference: "PB-SYN", title: "付款与回单待处理", nextStep: "已生成银行制单,等待付款完成并上传回单", ownerLabel: "财务", href: "/payment-batches?id=synthetic", mobileHref: "/mobile/payment-batches?id=synthetic" },
|
|
];
|
|
await mockVisualPortal(page, "finance", () => ({ total: items.length, items }));
|
|
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
|
await login(page, FINANCE);
|
|
await page.goto(mobile ? "/mobile" : "/portal");
|
|
await expect(page.getByText("报销待组建付款批次 · BX-SYN", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toBeVisible();
|
|
await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次 · BX-SYN" }))
|
|
.toHaveAttribute("href", mobile ? "/mobile/reimbursements/synthetic" : "/reimbursements/synthetic");
|
|
await expect(page.getByRole("link").filter({ hasText: "付款与回单待处理 · PB-SYN" }))
|
|
.toHaveAttribute("href", mobile ? "/mobile/payment-batches?id=synthetic" : "/payment-batches?id=synthetic");
|
|
await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0);
|
|
await expect(page.getByText("异常付款 1 项", { exact: true })).toHaveCount(0);
|
|
if (mobile) await expect(page.getByTestId("mobile-business-handoffs")).toContainText("2 项");
|
|
else await expect(page.getByTestId("portal-work-summary")).toContainText("2");
|
|
await page.screenshot({ path: testInfo.outputPath("handoffs.png"), fullPage: true });
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
|
|
items = [];
|
|
await page.reload();
|
|
await expect(page.getByText("付款与回单待处理 · PB-SYN", { exact: true })).toHaveCount(0);
|
|
await expect(page.getByText("报销待组建付款批次 · BX-SYN", { exact: true })).toHaveCount(0);
|
|
await expect(page.getByText("待付款批次 9 个", { exact: true })).toHaveCount(0);
|
|
});
|
|
}
|
|
|
|
for (const role of ["employee", "ceo"] as const) {
|
|
for (const mobile of [false, true]) {
|
|
test(`非财务${role}的${mobile ? "手机" : "PC"}冷进入加载本人待办并在处理后消失`, async ({ page }, testInfo) => {
|
|
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
|
const handoff: NonNullable<PortalHomePayload["businessHandoffs"]>["items"][number] = role === "employee"
|
|
? { key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }
|
|
: { key: "payment-review:synthetic", kind: "payment_review", reference: "PB-SYN", title: "付款批次待复核", nextStep: "由非制单人复核", ownerLabel: "非制单复核人", href: "/payment-batches?id=synthetic", mobileHref: "/mobile/payment-batches?id=synthetic" };
|
|
let items = [handoff];
|
|
await mockVisualPortal(page, role, () => ({ total: items.length, items }));
|
|
await page.context().addCookies([{ name: "has_session", value: "1", url: String(testInfo.project.use.baseURL) }]);
|
|
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
|
await page.goto(mobile ? "/mobile" : "/portal");
|
|
const row = page.getByRole("link").filter({ hasText: handoff.title });
|
|
await expect(row).toHaveCount(1);
|
|
await expect(row).toHaveAttribute("href", mobile ? handoff.mobileHref : handoff.href);
|
|
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
|
|
await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次" })).toHaveCount(0);
|
|
await page.screenshot({ path: testInfo.outputPath("nonfinance-handoffs.png"), fullPage: true });
|
|
items = [];
|
|
const refreshed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
|
|
await page.reload();
|
|
await refreshed;
|
|
await expect(row).toHaveCount(0);
|
|
await expect(page.getByTestId("mobile-business-handoffs")).toHaveCount(0);
|
|
});
|
|
}
|
|
|
|
test(`非财务${role}手机待办加载失败可重试,不把错误当空态`, async ({ page }, testInfo) => {
|
|
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
|
const handoff: NonNullable<PortalHomePayload["businessHandoffs"]>["items"][number] = role === "employee"
|
|
? { key: "reimbursement-edit:retry", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/retry", mobileHref: "/mobile/reimbursements/retry" }
|
|
: { key: "payment-review:retry", kind: "payment_review", reference: "PB-SYN", title: "付款批次待复核", nextStep: "由非制单人复核", ownerLabel: "非制单复核人", href: "/payment-batches?id=retry", mobileHref: "/mobile/payment-batches?id=retry" };
|
|
await mockVisualPortal(page, role, () => ({ total: 1, items: [handoff] }));
|
|
let unavailable = true;
|
|
await page.route("**/api/portal/home", async (route) => {
|
|
if (unavailable) await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "synthetic unavailable" } }) });
|
|
else await route.fallback();
|
|
});
|
|
await page.context().addCookies([{ name: "has_session", value: "1", url: String(testInfo.project.use.baseURL) }]);
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.goto("/mobile");
|
|
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toBeVisible();
|
|
await expect(page.getByTestId("mobile-business-handoffs")).toHaveCount(0);
|
|
unavailable = false;
|
|
await page.getByRole("button", { name: "重试加载待办", exact: true }).click();
|
|
await expect(page.getByRole("link").filter({ hasText: handoff.title })).toBeVisible();
|
|
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toHaveCount(0);
|
|
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
|
|
});
|
|
}
|
|
|
|
for (const mobile of [false, true]) {
|
|
for (const scenario of ["slow", "failure", "late", "late401"] as const) {
|
|
test(`门户身份隔离 ${mobile ? "手机" : "PC"} 同文档切账号 ${scenario}`, async ({ page }) => {
|
|
test.skip(process.env.E2E_MODE === "live", "仅本地合成账号,不访问公网");
|
|
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
|
let actor: "first" | "second" = "first";
|
|
let holdOldRequest = false;
|
|
let oldRequestStarted = false;
|
|
let oldRequestFinished = false;
|
|
let oldRequestAborted = false;
|
|
let holdFirstReturn = false;
|
|
let firstReturnStarted = false;
|
|
let refreshRequests = 0;
|
|
let secondFails = scenario === "failure";
|
|
const requestCounts = { first: 0, second: 0 };
|
|
const secondResponse = responseGate();
|
|
const oldResponse = responseGate();
|
|
const firstReturnResponse = responseGate();
|
|
const handoff = (identity: "first" | "second") => ({
|
|
key: `reimbursement-edit:synthetic-cache-${identity}`, kind: "reimbursement_edit" as const,
|
|
reference: `BX-SYN-CACHE-${identity}`, title: identity === "first" ? "合成甲待办" : "合成乙待办",
|
|
nextStep: "修改后重新提交", ownerLabel: "报销人",
|
|
href: `/reimbursements/synthetic-cache-${identity}`, mobileHref: `/mobile/reimbursements/synthetic-cache-${identity}`,
|
|
});
|
|
await mockVisualPortal(page, "employee");
|
|
const requestActor = (authorization?: string) => authorization === "Bearer synthetic-first" ? "first" : "second";
|
|
page.on("requestfailed", (request) => {
|
|
if (oldRequestStarted && new URL(request.url()).pathname === "/api/portal/home" && requestActor(request.headers().authorization) === "first") oldRequestAborted = true;
|
|
});
|
|
await page.route("**/api/auth/login", async (route) => {
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: `synthetic-${actor}`, expiresIn: 900, mustChangePassword: false })) });
|
|
});
|
|
await page.route("**/api/auth/refresh", async (route) => {
|
|
refreshRequests += 1;
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: `synthetic-${actor}`, expiresIn: 900, mustChangePassword: false })) });
|
|
});
|
|
await page.route("**/api/me", async (route) => {
|
|
const profileActor = requestActor(route.request().headers().authorization);
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({
|
|
id: `synthetic-cache-${profileActor}`, employeeNo: `SYN-${profileActor}`, name: profileActor === "first" ? "合成员工甲" : "合成员工乙",
|
|
email: `${profileActor}@example.test`, roleCodes: ["employee"], notificationPushEnabled: true, wecomBinding: null,
|
|
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
|
|
})) });
|
|
});
|
|
await page.route("**/api/portal/home", async (route) => {
|
|
const requestedActor = requestActor(route.request().headers().authorization);
|
|
requestCounts[requestedActor] += 1;
|
|
const delayedOldRequest = requestedActor === "first" && holdOldRequest;
|
|
if (delayedOldRequest) {
|
|
oldRequestStarted = true;
|
|
await oldResponse.pending;
|
|
if (scenario === "late401") {
|
|
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_002" } }) });
|
|
oldRequestFinished = true;
|
|
return;
|
|
}
|
|
}
|
|
if (requestedActor === "first" && holdFirstReturn) {
|
|
firstReturnStarted = true;
|
|
await firstReturnResponse.pending;
|
|
}
|
|
if (requestedActor === "second") await secondResponse.pending;
|
|
if (requestedActor === "second" && secondFails) {
|
|
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "synthetic unavailable" } }) });
|
|
} else {
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({
|
|
...portalPayload("employee"), businessHandoffs: { total: 1, items: [handoff(requestedActor)] },
|
|
pendingTasks: { count: 0, href: "/workflow/tasks" }, todoSummary: null, weeklyReport: null, projectSummary: null,
|
|
})) });
|
|
}
|
|
if (delayedOldRequest) oldRequestFinished = true;
|
|
});
|
|
try {
|
|
await page.goto(`/login?redirect=${mobile ? "/mobile" : "/portal"}`);
|
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
|
await page.getByTestId("login-password").fill("SyntheticOnly123!");
|
|
await page.getByTestId("login-submit").click();
|
|
await expect(page).toHaveURL(mobile ? /\/mobile$/ : /\/portal$/);
|
|
const firstRow = page.getByRole("link").filter({ hasText: handoff("first").title });
|
|
const secondRow = page.getByRole("link").filter({ hasText: handoff("second").title });
|
|
await expect(firstRow).toBeVisible();
|
|
await page.evaluate(() => { Reflect.set(window, "__portalSwitchDocument", "same-document"); });
|
|
if (scenario === "late" || scenario === "late401") {
|
|
holdOldRequest = true;
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
|
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
|
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
|
});
|
|
await expect.poll(() => oldRequestStarted).toBe(true);
|
|
}
|
|
if (mobile) {
|
|
await page.locator('a[href="/mobile/me"]').last().click();
|
|
await page.getByRole("button", { name: "退出登录", exact: true }).click();
|
|
} else {
|
|
await page.getByTestId("topbar-profile-trigger").first().click();
|
|
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
|
|
}
|
|
await expect(page).toHaveURL(/\/login/);
|
|
actor = "second";
|
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
|
await page.getByTestId("login-password").fill("SyntheticOnly123!");
|
|
await page.getByTestId("login-submit").click();
|
|
if (mobile) {
|
|
await expect(page.getByTestId("mobile-me-profile-name")).toHaveText("合成员工乙");
|
|
await page.locator('a[href="/mobile"]').last().click();
|
|
} else {
|
|
await expect(page.getByTestId("topbar-profile-trigger").first()).toContainText("合成员工乙");
|
|
}
|
|
await expect.poll(() => requestCounts.second).toBeGreaterThan(0);
|
|
expect(await page.evaluate(() => Reflect.get(window, "__portalSwitchDocument"))).toBe("same-document");
|
|
await expect(firstRow).toHaveCount(0, { timeout: 5000 });
|
|
await expect(secondRow).toHaveCount(0);
|
|
secondResponse.release();
|
|
if (scenario === "failure") {
|
|
const retry = mobile ? page.getByRole("button", { name: "重试加载待办", exact: true }) : page.getByTestId("portal-load-retry");
|
|
await expect(retry).toBeVisible();
|
|
await expect(firstRow).toHaveCount(0);
|
|
secondFails = false;
|
|
await retry.click();
|
|
}
|
|
await expect(secondRow).toBeVisible();
|
|
if (scenario === "late" || scenario === "late401") {
|
|
if (scenario === "late401") await expect.poll(() => oldRequestAborted, { timeout: 5000 }).toBe(true);
|
|
const refreshRequestsBeforeOldResponse = refreshRequests;
|
|
oldResponse.release();
|
|
await expect.poll(() => oldRequestFinished).toBe(true);
|
|
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
if (scenario === "late401") expect(refreshRequests).toBe(refreshRequestsBeforeOldResponse);
|
|
}
|
|
await expect(firstRow).toHaveCount(0);
|
|
await expect(secondRow).toHaveCount(1);
|
|
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
|
|
if (scenario === "late401") {
|
|
if (mobile) {
|
|
await page.locator('a[href="/mobile/me"]').last().click();
|
|
await page.getByRole("button", { name: "退出登录", exact: true }).click();
|
|
} else {
|
|
await page.getByTestId("topbar-profile-trigger").first().click();
|
|
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
|
|
}
|
|
await expect(page).toHaveURL(/\/login/);
|
|
actor = "first";
|
|
holdOldRequest = false;
|
|
holdFirstReturn = true;
|
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
|
await page.getByTestId("login-password").fill("SyntheticOnly123!");
|
|
await page.getByTestId("login-submit").click();
|
|
if (mobile) {
|
|
await expect(page.getByTestId("mobile-me-profile-name")).toHaveText("合成员工甲");
|
|
await page.locator('a[href="/mobile"]').last().click();
|
|
} else {
|
|
await expect(page.getByTestId("topbar-profile-trigger").first()).toContainText("合成员工甲");
|
|
}
|
|
await expect.poll(() => firstReturnStarted).toBe(true);
|
|
expect(await page.evaluate(() => Reflect.get(window, "__portalSwitchDocument"))).toBe("same-document");
|
|
await expect(secondRow).toHaveCount(0);
|
|
firstReturnResponse.release();
|
|
await expect(firstRow).toBeVisible();
|
|
await expect(secondRow).toHaveCount(0);
|
|
}
|
|
} finally {
|
|
secondResponse.release();
|
|
oldResponse.release();
|
|
firstReturnResponse.release();
|
|
await page.unrouteAll({ behavior: "wait" });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const mobile of [false, true]) {
|
|
test(`门户身份隔离 ${mobile ? "手机" : "PC"} 同身份401刷新仍可用`, async ({ page }) => {
|
|
test.skip(process.env.E2E_MODE === "live", "仅本地合成认证,不访问公网");
|
|
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
|
let portalRequests = 0;
|
|
let refreshRequests = 0;
|
|
await mockVisualPortal(page, "employee", () => ({ total: 0, items: [] }));
|
|
await page.route("**/api/auth/refresh", async (route) => {
|
|
refreshRequests += 1;
|
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) });
|
|
});
|
|
await page.route("**/api/portal/home", async (route) => {
|
|
portalRequests += 1;
|
|
if (portalRequests === 1) {
|
|
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_002" } }) });
|
|
} else {
|
|
await route.fallback();
|
|
}
|
|
});
|
|
await page.goto(`/login?redirect=${mobile ? "/mobile" : "/portal"}`);
|
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
|
await page.getByTestId("login-password").fill("SyntheticOnly123!");
|
|
const loaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home" && response.status() === 200);
|
|
await page.getByTestId("login-submit").click();
|
|
await expect(page).toHaveURL(mobile ? /\/mobile$/ : /\/portal$/);
|
|
expect((await (await loaded).json()).data.businessHandoffs.total).toBe(0);
|
|
await expect.poll(() => portalRequests).toBe(2);
|
|
expect(refreshRequests).toBe(1);
|
|
if (mobile) {
|
|
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
|
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toHaveCount(0);
|
|
} else {
|
|
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
|
|
await expect(page.getByTestId("portal-load-error")).toHaveCount(0);
|
|
}
|
|
});
|
|
}
|
|
|
|
test("超过八条业务事项时计数与展示不截断", async ({ page }) => {
|
|
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
|
|
const items: NonNullable<PortalHomePayload["businessHandoffs"]>["items"] = Array.from({ length: 9 }, (_, index) => ({
|
|
key: `reimbursement-batch:synthetic-${index}`, kind: "reimbursement_batch", reference: `BX-SYN-${index}`,
|
|
title: "报销待组建付款批次", nextStep: "核对付款资料后组批", ownerLabel: "财务",
|
|
href: `/reimbursements/synthetic-${index}`, mobileHref: `/mobile/reimbursements/synthetic-${index}`,
|
|
}));
|
|
await mockVisualPortal(page, "finance", () => ({ total: items.length, items }));
|
|
await login(page, FINANCE);
|
|
await page.goto("/portal");
|
|
await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次" })).toHaveCount(9);
|
|
await expect(page.getByTestId("portal-work-summary")).toContainText("9");
|
|
});
|
|
|
|
test("员工 Portal 视觉基线", async ({ page }, testInfo) => {
|
|
await mockVisualPortal(page, "employee");
|
|
await page.setViewportSize({ width: 1440, height: 1200 });
|
|
await login(page, EMPLOYEE);
|
|
|
|
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
|
|
await expect(page.getByRole("heading", { 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 attachScreenshot(page, testInfo, "portal-employee.png");
|
|
});
|
|
|
|
test("管理员 Portal 视觉基线", async ({ page }, testInfo) => {
|
|
await mockVisualPortal(page, "admin");
|
|
await page.setViewportSize({ width: 1440, height: 1200 });
|
|
await login(page, ADMIN);
|
|
|
|
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0);
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).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();
|
|
await expect(page.getByText("试点观测摘要")).not.toBeVisible();
|
|
|
|
await attachScreenshot(page, testInfo, "portal-admin.png");
|
|
});
|
|
|
|
test("财务 Portal 显示财务待处理入口", async ({ page }, testInfo) => {
|
|
await mockVisualPortal(page, "finance");
|
|
await page.setViewportSize({ width: 1440, height: 1200 });
|
|
await login(page, FINANCE);
|
|
|
|
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
|
await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible();
|
|
|
|
await attachScreenshot(page, testInfo, "portal-finance.png");
|
|
});
|
|
});
|