import { mkdir, writeFile } from "node:fs/promises"; import { mkdirSync, mkdtempSync } from "node:fs"; import path from "node:path"; import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test"; const SCREENSHOT_ROOT = process.env.QA_VISUAL_OUTPUT_DIR || path.resolve(process.cwd(), "../output/playwright/pc-frontend-visual-consistency-wave1"); mkdirSync(SCREENSHOT_ROOT, { recursive: true }); const SCREENSHOT_DIR = mkdtempSync(path.join(SCREENSHOT_ROOT, "visual-shell-")); test.use({ serviceWorkers: "block" }); function envelope(data: unknown) { return { requestId: "mock-r96-ui", data }; } async function fulfillJson(route: Route, data: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } const visualMe = { id: "employee-r96", employeeNo: "R96001", name: "陈工程", phone: "13800000000", email: "employee@hyinda.com", departmentId: "dept-r96", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["manager"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50, }, wecomBinding: null, }; function navigationItem(key: string, href: string, label: string, icon: string) { return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; } async function mockVisualApis(page: Page) { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname; if (path === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (path === "/api/auth/capabilities") { await fulfillJson(route, { emailDelivery: { ready: true, statusReasons: [], }, emailLoginCode: { enabled: true, }, passwordReset: { enabled: true, }, }); return; } if (path === "/api/auth/wecom/config") { await fulfillJson(route, { enabled: true, entryEnabled: true, oauthEnabled: true, corpId: "wx-test-corp", agentId: "1000002", loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal", bindEnabled: true, defaultReturnTo: "/portal", status: "ready", statusReasons: [], }); return; } if (path === "/api/auth/login" || path === "/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: "mock-r96-access-token", expiresIn: 900, mustChangePassword: false })), }); return; } if (path === "/api/me") { await fulfillJson(route, visualMe); return; } if (path === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: "workspace-r96", 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: [ navigationItem("portal", "/portal", "工作台", "home_app_logo"), navigationItem("approvals", "/workflow/tasks", "待我审批", "fact_check"), navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"), navigationItem("purchase", "/purchase-requests", "采购", "shopping_cart"), navigationItem("contracts", "/contracts-registry", "合同", "description"), navigationItem("projects", "/projects", "项目", "workspaces"), navigationItem("assets", "/assets", "资产", "inventory_2"), ], admin: [], backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")], }, recommendedRolePresets: [], }); return; } if (path === "/api/portal/home") { await fulfillJson(route, { workspace: { id: "workspace-r96", persisted: true, profile: "small_team_internal" }, greeting: { name: visualMe.name }, cards: [ { key: "pending_tasks", title: "待处理", value: 1, href: "/workflow/tasks", severity: "warning" }, { key: "my_weekly_report", title: "周报", value: "待填写", href: "/weekly-reports" }, ], pendingTasks: { count: 1, href: "/workflow/tasks" }, myApplications: [{ domain: "reimbursement", count: 2, href: "/reimbursements" }], riskSummary: null, announcements: [{ id: "ann-r96", title: "试运行提醒", summary: "今天先处理待办。", href: null }], quickLinks: [{ key: "invoice", title: "上传票据", href: "/mobile/capture" }], todayPresence: { date: "2026-05-06", status: "office", label: "在岗", note: null, href: "/team" }, teamPresence: null, weeklyReport: { weekStart: "2026-05-04", status: "draft", needsHelp: false, href: "/weekly-reports" }, weeklySummary: null, todoSummary: { openCount: 1, highPriorityCount: 0, href: "/todos" }, projectSummary: null, peopleLifecycle: null, adoptionSummary: null, recentActivities: [], teamStatusBoard: [], }); return; } if (path === "/api/attendance/me/today") { await fulfillJson(route, { workDate: "2026-05-06", needClockIn: true, exemptionReason: null, item: null, }); return; } if (path === "/api/workflow/tasks") { await fulfillJson(route, { items: [ { id: "task-r96", stepName: "经理审批", status: "pending", createdAt: "2026-05-06T09:00:00.000Z", completedAt: null, workflowInstance: { entityType: "reimbursement", entityId: "rb-r96" }, allowedActions: ["approve"], }, ], total: 1, page: 1, pageSize: 3, }); return; } if (path === "/api/workflow/tasks/summary") { await fulfillJson(route, { scope: url.searchParams.get("scope") === "system" ? "system" : "mine", pendingTotal: 1, overdueTotal: 0, earliestPendingAt: "2026-05-06T09:00:00.000Z", averagePendingAgeHours: 2, byEntityType: [ { key: "reimbursement", pendingTotal: 1, overdueTotal: 0, }, ], byStep: [], byAssigneeRole: [], }); return; } if (path === "/api/notifications") { await fulfillJson(route, { items: [ { id: "notice-r96", type: "workflow.task_assigned", title: "审批提醒", body: "有 1 条待办。", targetType: "workflow_task", targetId: "task-r96", readAt: null, createdAt: "2026-05-06T09:10:00.000Z", }, ], total: 1, page: 1, pageSize: 3, unreadOnly: false, unreadCount: 1, }); return; } if (path === "/api/reimbursement-templates") { await fulfillJson(route, { enabled: true, items: [] }); return; } if (path === "/api/reimbursements/linkable-travel-requests") { await fulfillJson(route, { items: [] }); return; } if (path === "/api/reimbursements") { const requestedStatus = url.searchParams.get("status"); const reviewingItem = { id: "rb-r96-reviewing", formNumber: "BX20260506002", status: "reviewing", reimbursementDate: "2026-05-06", totalAmount: "320.00", description: "待处理报销", payment: { paymentVoucherNo: null, paymentDate: null, paymentChannel: null, paidAt: null, paidBy: null, }, owner: { id: "employee-r96", employeeNo: "R96001", name: "陈工程", }, submitter: { id: "employee-r96", employeeNo: "R96001", name: "陈工程", }, project: null, pendingWorkflow: { task: { id: "task-r96", stepName: "经理审批", assigneeEmployeeId: "employee-r96", assigneeRoleCode: "manager", status: "pending", createdAt: "2026-05-06T09:00:00.000Z", }, allowedActions: ["approve", "reject"], }, createdAt: "2026-05-06T09:00:00.000Z", updatedAt: "2026-05-06T09:00:00.000Z", }; const draftItem = { id: "rb-r96", formNumber: "BX20260506001", status: "draft", reimbursementDate: "2026-05-06", totalAmount: "168.30", description: "视觉基线报销", payment: { paymentVoucherNo: null, paymentDate: null, paymentChannel: null, paidAt: null, paidBy: null, }, owner: { id: "employee-r96", employeeNo: "R96001", name: "陈工程", }, submitter: { id: "employee-r96", employeeNo: "R96001", name: "陈工程", }, project: null, pendingWorkflow: null, createdAt: "2026-05-06T09:00:00.000Z", updatedAt: "2026-05-06T09:00:00.000Z", }; const items = requestedStatus === "reviewing" ? [reviewingItem] : [draftItem]; await fulfillJson(route, { page: 1, pageSize: 10, total: items.length, scope: url.searchParams.get("scope") ?? "own", items, }); return; } await fulfillJson(route, {}); }); } async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { const body = await page.screenshot({ fullPage: true }); await testInfo.attach(name, { body, contentType: "image/png", }); await mkdir(SCREENSHOT_DIR, { recursive: true }); await writeFile(path.join(SCREENSHOT_DIR, name), body); } async function loginVisualUser(page: Page) { await page.goto("/login"); await expect(page.getByTestId("login-password-primary-panel")).toBeVisible(); await page.getByTestId("login-identifier").fill("employee@hyinda.com"); await page.getByTestId("login-password").fill("EmployeePassword1234"); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/portal/); } test.describe("visual shell", () => { test("登录页入口布局基线", async ({ page }, testInfo) => { await mockVisualApis(page); await page.setViewportSize({ width: 1440, height: 1024 }); await page.goto("/login"); await expect(page.getByText("海音达工作台").first()).toBeVisible(); await expect(page.getByRole("heading", { name: "海音达 OA" })).toBeVisible(); await expect(page.getByTestId("login-password-primary-panel")).toBeVisible(); await attachScreenshot(page, testInfo, "login-shell.png"); }); test("登录后壳层布局基线", async ({ page }, testInfo) => { await mockVisualApis(page); await page.setViewportSize({ width: 1440, height: 1024 }); await loginVisualUser(page); await expect(page.locator("aside")).toBeVisible(); await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "工作台", exact: true })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "采购" })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "合同" })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "项目" })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "资产" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "更多" })).toBeVisible(); await attachScreenshot(page, testInfo, "app-shell.png"); }); test("PC 首页与报销列表收敛布局基线", async ({ page }, testInfo) => { await mockVisualApis(page); await page.setViewportSize({ width: 1440, height: 1024 }); await loginVisualUser(page); 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 attachScreenshot(page, testInfo, "pc-portal-wave1.png"); await page.goto("/reimbursements"); await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible(); await expect(page.getByTestId("reimbursements-create")).toBeVisible(); await expect(page.getByTestId("reimbursements-export")).toBeVisible(); await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible(); await expect(page.getByTestId("reimbursements-table")).toBeVisible(); await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0); await attachScreenshot(page, testInfo, "pc-reimbursements-wave1.png"); await page.goto("/reimbursements?status=reviewing&scope=all"); await expect(page.getByTestId("reimbursements-table")).toBeVisible(); await expect(page.getByTestId("reimbursement-detail-link").filter({ hasText: "处理" })).toBeVisible(); await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0); await expect(page.getByRole("button", { name: "批量通过" })).toHaveCount(0); await expect(page.getByRole("button", { name: "批量驳回" })).toHaveCount(0); }); test("移动首页任务化布局基线", async ({ page }, testInfo) => { await mockVisualApis(page); await page.setViewportSize({ width: 390, height: 844 }); await loginVisualUser(page); await page.goto("/mobile"); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); await expect(page.getByTestId("mobile-today-focus")).toBeVisible(); await expect(page.getByTestId("mobile-action-capture")).toBeVisible(); await expect(page.getByTestId("mobile-attendance-summary-card")).toHaveCount(0); await attachScreenshot(page, testInfo, "mobile-today.png"); }); test("待我审批手机卡片布局基线", async ({ page }, testInfo) => { await mockVisualApis(page); await page.setViewportSize({ width: 390, height: 844 }); await loginVisualUser(page); await page.goto("/workflow/tasks"); await expect(page.getByTestId("workflow-tasks-mobile-list")).toBeVisible(); await expect(page.getByTestId("workflow-tasks-table")).toBeHidden(); await expect(page.getByRole("link", { name: "处理" })).toBeVisible(); await attachScreenshot(page, testInfo, "r97-workflow-tasks-mobile.png"); }); });