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: "admin@hyinda.com", 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(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((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["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["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["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("first@example.test"); 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("second@example.test"); 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((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("first@example.test"); 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("synthetic@example.test"); 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["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"); }); });