import { mkdirSync } from "node:fs"; import path from "node:path"; import { expect, test, type Page, type Route } from "@playwright/test"; function envelope(data: unknown) { return { requestId: "mock-r103-ui-consistency", data }; } async function fulfillJson(route: Route, data: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103"); const taskScreenshotDir = path.resolve( process.cwd(), "../output/playwright/07-18-small-team-internal-readiness", ); function screenshotPath(fileName: string) { mkdirSync(screenshotDir, { recursive: true }); return path.join(screenshotDir, fileName); } function taskScreenshotPath(fileName: string) { mkdirSync(taskScreenshotDir, { recursive: true }); return path.join(taskScreenshotDir, fileName); } const me = { id: "employee-r103", employeeNo: "HYD000103", name: "陈工程", phone: "13800000000", email: "employee@hyinda.com", departmentId: "dept-r103", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["employee"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50, }, wecomBinding: null, }; const purchaseId = "purchase-r103"; const reimbursementId = "11111111-1111-4111-8111-111111111111"; const invoiceId = "invoice-r103"; const projectId = "project-r103"; const assetId = "asset-r103"; const safePurchaseAttachmentId = "attachment-safe-r103"; const quarantinedPurchaseAttachmentId = "attachment-quarantined-r103"; const safePngBuffer = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==", "base64", ); function navigationItem(key: string, href: string, label: string, icon: string) { return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; } function purchaseSummary() { return { id: purchaseId, requestNo: "CG-20260506-0001", applicantId: me.id, submitterId: me.id, departmentId: me.departmentId, legalEntityId: "legal-r103", costCenterId: "cost-r103", supplierId: "supplier-r103", documentCategoryId: "category-r103", budgetPoolId: null, title: "办公用品采购", reason: "日常办公", currencyCode: "CNY", totalAmount: "299.00", expectedDeliveryDate: "2026-05-10", status: "draft", createdAt: "2026-05-06T09:00:00.000Z", updatedAt: "2026-05-06T09:10:00.000Z", applicant: { id: me.id, employeeNo: me.employeeNo, name: me.name }, submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name }, department: { id: me.departmentId, name: "研发部" }, legalEntity: { id: "legal-r103", code: "HYD", name: "海音达" }, costCenter: { id: "cost-r103", code: "RD", name: "研发部", legalEntityId: "legal-r103" }, supplier: { id: "supplier-r103", code: "SUP", name: "测试供应商" }, documentCategory: { id: "category-r103", code: "CG", name: "采购申请", entityType: "purchase_request" }, budgetPool: null, }; } function purchaseDetail() { return { ...purchaseSummary(), items: [ { id: "purchase-item-r103", description: "键盘", specification: "无线", quantity: "1.00", unitPrice: "299.00", lineAmount: "299.00", isAssetCandidate: false, createdAt: "2026-05-06T09:00:00.000Z", updatedAt: "2026-05-06T09:10:00.000Z", }, ], attachments: [ { id: safePurchaseAttachmentId, originalName: "采购报价.png", mimeType: "image/png", fileSize: safePngBuffer.length, fileSha256: null, governanceStatus: "approved", sensitivityLevel: "internal", attachmentType: "quote", downloadPolicy: "reason_required", reviewedBy: null, reviewedAt: null, reviewNote: null, archivedBy: null, archivedAt: null, archiveNote: null, uploadedBy: me.id, createdAt: "2026-05-06T09:20:00.000Z", uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name }, }, { id: quarantinedPurchaseAttachmentId, originalName: "历史隔离附件.pdf", mimeType: "application/pdf", fileSize: 128, fileSha256: null, governanceStatus: "quarantined", sensitivityLevel: "internal", attachmentType: "quote", downloadPolicy: "standard", reviewedBy: null, reviewedAt: null, reviewNote: null, archivedBy: null, archivedAt: null, archiveNote: null, uploadedBy: me.id, createdAt: "2026-05-06T09:15:00.000Z", uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name }, }, ], pendingWorkflow: null, archiveOrder: null, permissions: { canEditDraft: true, canSubmit: true, canWithdraw: false, canArchiveOrder: false, }, riskFlags: [], }; } function invoiceItem() { return { id: invoiceId, status: "available", ocrType: "mixed", ocrProvider: "mock", ocrAttempts: 1, ocrLastError: null, ocrCompletedAt: "2026-05-06T09:00:00.000Z", invoiceCode: "044002600111", invoiceNumber: "12345678", invoiceDate: "2026-05-06", amount: "100.00", taxAmount: "6.00", totalAmount: "106.00", linkedReimbursementId: null, linkedItemId: null, linkedTravelOrderId: null, originalName: "发票.pdf", mimeType: "application/pdf", fileSize: 1024, verification: null, owner: { id: me.id, employeeNo: me.employeeNo, name: me.name }, uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name }, createdAt: "2026-05-06T09:00:00.000Z", updatedAt: "2026-05-06T09:10:00.000Z", }; } function reimbursementForBind() { return { id: reimbursementId, formNumber: null, status: "draft", reimbursementDate: "2026-05-06", totalAmount: "106.00", description: "发票绑定测试", payment: { paymentVoucherNo: null, paymentDate: null, paymentChannel: null, paidAt: null, paidBy: null, }, owner: { id: me.id, employeeNo: me.employeeNo, name: me.name }, submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name }, project: null, pendingWorkflow: null, createdAt: "2026-05-06T09:00:00.000Z", updatedAt: "2026-05-06T09:10:00.000Z", }; } function projectListItem() { return { id: projectId, projectNo: "PRJ-20260718-001", name: "内部候选项目", clientName: "合成客户", clientAlias: "合成客户", status: "active", startsAt: "2026-07-01", targetEndAt: "2026-08-31", actualEndAt: null, summary: "用于验证中等宽度下项目操作列可达", isConfidential: false, owner: { id: me.id, employeeNo: me.employeeNo, name: me.name }, manager: null, counts: { members: 2, milestones: 3, tasks: 5, risks: 1, overdueMilestones: 0, highPriorityTasks: 1, openRisks: 1, }, updatedAt: "2026-07-18T08:00:00.000Z", }; } function assetListItem() { return { id: assetId, assetNo: "FA-20260718-000001", name: "研发测试笔记本电脑", assetType: "fixed_asset", sensitivityLevel: "internal", categoryId: "asset-category-r103", legalEntityId: "legal-r103", costCenterId: "cost-r103", purchaseRequestId: null, serialNo: "SYNTHETIC-R103", location: "办公室 A 区", status: "in_use", assigneeEmployeeId: me.id, ownerEmployeeId: me.id, keeperEmployeeId: me.id, archiveLocation: null, externalRef: null, sourceHash: null, verificationNo: null, confidentialityNote: null, metadata: null, lastVerifiedAt: "2026-07-17T08:00:00.000Z", expiresAt: null, activatedAt: "2026-07-01", retiredAt: null, createdAt: "2026-07-01T08:00:00.000Z", updatedAt: "2026-07-18T08:00:00.000Z", category: { id: "asset-category-r103", code: "COMPUTER", name: "电脑设备", documentCategoryId: null, documentCategory: null, }, legalEntity: { id: "legal-r103", code: "HYD", name: "海音达" }, costCenter: { id: "cost-r103", code: "RD", name: "研发部", legalEntityId: "legal-r103" }, purchaseRequest: null, assignee: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } }, owner: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } }, keeper: { id: me.id, employeeNo: me.employeeNo, name: me.name, department: { id: me.departmentId, name: "研发部" } }, permissions: { canEdit: false }, }; } async function mockApis(page: Page, options: { roleCodes?: string[] } = {}) { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const url = new URL(request.url()); const pathName = url.pathname; if (pathName === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (pathName === "/api/auth/wecom/config") { await fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [], }); return; } if (pathName === "/api/auth/login" || 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: "mock-r103-token", expiresIn: 900, mustChangePassword: false })), }); return; } if (pathName === "/api/me") { await fulfillJson(route, { ...me, roleCodes: options.roleCodes ?? me.roleCodes }); return; } if (pathName === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: "workspace-r103", 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("reimbursements", "/reimbursements", "报销", "receipt_long"), navigationItem("purchase", "/purchase-requests", "采购", "shopping_cart"), navigationItem("projects", "/projects", "项目", "topic"), navigationItem("assets", "/assets", "资产", "inventory_2"), navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"), ], admin: [], backup: [ navigationItem("payment", "/payment-batches", "付款", "payments"), navigationItem("dashboard", "/dashboard", "数据看板", "monitoring"), ], }, recommendedRolePresets: [], }); return; } if (pathName === "/api/notifications") { await fulfillJson(route, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] }); return; } if (pathName === "/api/portal/home") { await fulfillJson(route, { workspace: { id: "workspace-r103", persisted: true, profile: "small_team_internal" }, greeting: { name: me.name }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], todayPresence: null, teamPresence: null, weeklyReport: null, weeklySummary: null, todoSummary: null, projectSummary: null, peopleLifecycle: null, adoptionSummary: null, recentActivities: [], teamStatusBoard: [], }); return; } if (pathName === "/api/master-data/legal-entities") { await fulfillJson(route, { includeArchived: false, items: [{ id: "legal-r103", code: "HYD", name: "海音达", shortName: null, currencyCode: "CNY", taxNo: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] }); return; } if (pathName === "/api/master-data/cost-centers") { await fulfillJson(route, { includeArchived: false, items: [{ id: "cost-r103", legalEntityId: "legal-r103", code: "RD", name: "研发部", parentId: null, ownerEmployeeId: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] }); return; } if (pathName === "/api/master-data/suppliers") { await fulfillJson(route, { includeArchived: false, items: [{ id: "supplier-r103", code: "SUP", name: "测试供应商", taxNo: null, contactName: null, contactPhone: null, bankName: null, bankAccount: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] }); return; } if (pathName === "/api/master-data/document-categories") { await fulfillJson(route, { includeArchived: false, items: [{ id: "category-r103", code: "CG", name: "采购申请", entityType: "purchase_request", sequencePrefix: "CG", numberLength: 4, resetPeriod: "daily", enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] }); return; } if (pathName === "/api/purchase-requests" && request.method() === "GET") { const summary = purchaseSummary(); const queryStatus = url.searchParams.get("status"); const items = !queryStatus || queryStatus === summary.status ? [summary] : []; await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, mine: true, items }); return; } if (pathName === "/api/projects" && request.method() === "GET") { await fulfillJson(route, { items: [projectListItem()] }); return; } if (pathName === "/api/contracts-registry/expiring" && request.method() === "GET") { await fulfillJson(route, { days: 30, total: 0, items: [] }); return; } if (pathName === "/api/contracts-registry" && request.method() === "GET") { await fulfillJson(route, { items: [] }); return; } if (pathName === "/api/payment-batches" && request.method() === "GET") { await fulfillJson(route, { items: [] }); return; } if (pathName === "/api/ap-operations" && request.method() === "GET") { await fulfillJson(route, { summary: { pendingPaymentCount: 0, partialMatchCount: 0, exceptionCount: 0, budgetOverspendCount: 0, }, pendingPaymentBatches: [], failedPaymentBatchItems: [], exceptionRiskFlags: [], budgetOverspendRisks: [], partialMatchItems: [], }); return; } if (pathName === "/api/ap-operations/reimbursement-ledger" && request.method() === "GET") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }); return; } if (pathName === "/api/risk-flags" && request.method() === "GET") { await fulfillJson(route, { items: [] }); return; } if (pathName === "/api/asset-categories" && request.method() === "GET") { await fulfillJson(route, { includeArchived: false, items: [ { id: "asset-category-r103", code: "COMPUTER", name: "电脑设备", documentCategoryId: null, enabled: true, archivedAt: null, createdAt: "2026-07-01T08:00:00.000Z", updatedAt: "2026-07-18T08:00:00.000Z", documentCategory: null, }, ], }); return; } if (pathName === "/api/assets" && request.method() === "GET") { await fulfillJson(route, { mine: true, items: [assetListItem()] }); return; } if (pathName === `/api/purchase-requests/${purchaseId}` && request.method() === "PATCH") { await fulfillJson(route, { ...purchaseDetail(), ...request.postDataJSON() }); return; } if (pathName === `/api/purchase-requests/${purchaseId}` && request.method() === "GET") { await fulfillJson(route, purchaseDetail()); return; } if (pathName === `/api/purchase-requests/${purchaseId}/attachments` && request.method() === "POST") { if (request.postDataBuffer()?.includes(Buffer.from('filename="unsupported.txt"'))) { await route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ requestId: "mock-r103-unsupported-attachment", error: { code: "VALID_001", message: "参数校验失败", details: { file: "unsupported_type" }, }, }), }); return; } await fulfillJson(route, { attachment: { id: "attachment-r103", originalName: "采购报价.png", mimeType: "image/png", fileSize: safePngBuffer.length, fileSha256: null, governanceStatus: "approved", sensitivityLevel: "internal", attachmentType: "quote", downloadPolicy: "standard", reviewedBy: null, reviewedAt: null, reviewNote: null, archivedBy: null, archivedAt: null, archiveNote: null, uploadedBy: me.id, createdAt: "2026-05-06T09:20:00.000Z", uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name }, }, }); return; } if ( pathName === `/api/purchase-requests/${purchaseId}/attachments/${safePurchaseAttachmentId}/download` && request.method() === "POST" ) { expect(request.headers().authorization).toBe("Bearer mock-r103-token"); expect(request.headers()["content-type"]).toContain("application/json"); expect(request.postDataJSON()).toEqual({ reason: "采购复核" }); await route.fulfill({ status: 200, headers: { "content-type": "application/octet-stream", "content-disposition": 'attachment; filename="purchase-attachment.png"', "x-content-type-options": "nosniff", }, body: safePngBuffer, }); return; } if (pathName === "/api/invoice-pool") { await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceItem()] }); return; } if (pathName === "/api/reimbursements") { await fulfillJson(route, { page: 1, pageSize: 50, total: 1, scope: "own", items: [reimbursementForBind()] }); return; } await fulfillJson(route, {}); }); } async function login(page: Page) { await page.goto("/login"); 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/); } async function expectResponsiveBusinessTable( page: Page, target: { path: string; heading: string; tableTestId: string; screenshotName: string }, ) { await page.goto(target.path); await expect(page.getByRole("heading", { name: target.heading, exact: true })).toBeVisible(); const table = page.getByTestId(target.tableTestId); await expect(table).toBeVisible(); const scrollRegion = table.locator("xpath=ancestor::*[@data-testid='business-table-scroll']").first(); await expect(scrollRegion).toBeVisible(); const metrics = await scrollRegion.evaluate((element) => { const style = window.getComputedStyle(element); element.scrollLeft = element.scrollWidth; return { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, overflowX: style.overflowX, documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, }; }); expect(["auto", "scroll"]).toContain(metrics.overflowX); expect(metrics.scrollWidth).toBeGreaterThanOrEqual(metrics.clientWidth); expect(metrics.documentOverflow).toBeLessThanOrEqual(2); const operation = table.getByRole("link").or(table.getByRole("button")).last(); await expect(operation).toBeVisible(); await operation.click({ trial: true }); await page.screenshot({ path: taskScreenshotPath(target.screenshotName), fullPage: true }); } async function expectLedgerHeaderActions( page: Page, target: { path: string; heading: string; expectedActions: string[] }, ) { await page.goto(target.path); const heading = page.getByRole("heading", { level: 1, name: target.heading, exact: true }); await expect(heading).toBeVisible(); const header = heading.locator("xpath=ancestor::header[1]"); await expect(header).toHaveClass(/page-header-shell/); const actionRegion = header.locator(":scope > .page-header-shell__actions"); if (target.expectedActions.length === 0) { await expect(actionRegion).toHaveCount(0); return; } await expect(actionRegion).toHaveCount(1); const actionLabels = await actionRegion.locator("button, a").evaluateAll((elements) => elements.map((element) => (element.textContent ?? "").replace(/\s+/g, " ").trim()), ); expect(actionLabels).toHaveLength(target.expectedActions.length); expect(actionLabels).toEqual(target.expectedActions); expect(actionLabels.length).toBeLessThanOrEqual(2); expect(actionLabels.filter((label) => label === "更多").length).toBeLessThanOrEqual(1); expect(actionLabels.filter((label) => label !== "更多").length).toBeLessThanOrEqual(1); expect(actionLabels.filter((label) => /^(筛选|导出(?:\s.*)?|刷新|显示测试数据)$/.test(label))).toEqual([]); } test.describe("R103 cross page UI consistency", () => { test.beforeEach(async ({ page }, testInfo) => { const roleCodes = testInfo.title.includes("七个主要业务台账") ? ["employee", "manager", "finance", "administration"] : testInfo.title.includes("资产长表单") ? ["employee", "administration"] : undefined; await mockApis(page, { roleCodes, }); await login(page); }); test("七个主要业务台账标题区保持一主操作加至多一个更多,列表工具不常驻", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 960 }); const targets = [ { path: "/purchase-requests", heading: "采购", expectedActions: ["新建采购"] }, { path: "/contracts-registry", heading: "合同", expectedActions: ["新增合同"] }, { path: "/projects", heading: "项目", expectedActions: ["新建项目"] }, { path: "/assets", heading: "资产", expectedActions: ["新增资产", "更多"] }, { path: "/payment-batches", heading: "付款", expectedActions: ["新建批次"] }, { path: "/ap-operations", heading: "应付账款", expectedActions: [] }, { path: "/invoice-pool", heading: "票据", expectedActions: ["上传票据"] }, ]; for (const target of targets) { await expectLedgerHeaderActions(page, target); if (target.path === "/assets") { const assetHeader = page .getByRole("heading", { level: 1, name: "资产", exact: true }) .locator("xpath=ancestor::header[1]"); await expect(assetHeader.getByRole("button", { name: "资产分类", exact: true })).toHaveCount(0); await assetHeader.getByRole("button", { name: "更多", exact: true }).click(); await page.getByRole("menuitem", { name: "资产分类", exact: true }).click(); await expect(page.getByTestId("asset-category-manager")).toBeVisible(); } } }); test("采购页使用安全格式上传并通过认证 Blob 下载", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto("/purchase-requests"); await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购")).toBeVisible(); await expect(page.getByRole("heading", { name: "采购" })).toBeVisible(); const purchaseHeader = page.locator(".page-header-shell").filter({ hasText: "采购" }).first(); await expect(purchaseHeader.getByRole("button", { name: "筛选" })).toHaveCount(0); await expect(page.getByTestId("purchase-requests-table")).toBeVisible(); await expect(page.getByTestId("purchase-advanced-filters")).toBeVisible(); await expect(page.getByTestId("purchase-attachment-upload")).toHaveCount(0); await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0); await expect(page.getByText("付款处理")).toHaveCount(0); await expect(page.getByText("/purchase-requests")).toHaveCount(0); await page.goto(`/purchase-requests?id=${purchaseId}`); await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0); await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0); await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件"); await expect(page.getByText("支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。")).toBeVisible(); await expect(page.getByText("Choose File")).toHaveCount(0); const attachmentInput = page.getByTestId("purchase-attachment-file-input"); await expect(attachmentInput).toHaveAttribute( "accept", ".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp", ); await attachmentInput.setInputFiles({ name: "采购报价.png", mimeType: "image/png", buffer: safePngBuffer, }); await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.png"); await attachmentInput.setInputFiles({ name: "unsupported.txt", mimeType: "text/plain", buffer: Buffer.from("synthetic unsupported attachment", "utf8"), }); await expect(page.getByRole("main").getByText("仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。")).toBeVisible(); let popupOpened = false; page.on("popup", () => { popupOpened = true; }); await page.getByTestId(`purchase-attachment-download-desktop-${safePurchaseAttachmentId}`).click(); await expect(page.getByRole("heading", { name: "填写下载用途" })).toBeVisible(); await page.getByRole("textbox", { name: "下载用途" }).fill("采购复核"); const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "继续下载" }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe("采购报价.png"); expect(popupOpened).toBe(false); await expect( page.getByTestId(`purchase-attachment-download-desktop-${quarantinedPurchaseAttachmentId}`), ).toBeDisabled(); await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true }); }); test("票据绑定报销单不展示 UUID 兜底", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto("/invoice-pool"); await expect(page.getByRole("heading", { name: "票据" })).toBeVisible(); const invoiceHeader = page.locator(".page-header-shell").filter({ hasText: "票据" }).first(); await expect(invoiceHeader.getByRole("button", { name: "绑定" })).toHaveCount(0); await expect(invoiceHeader.getByTestId("invoice-export")).toHaveCount(0); await expect(page.getByTestId("invoice-detail-panel")).toHaveCount(0); await page.getByRole("button", { name: "绑定" }).click(); await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06"); await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId); await expect(page.getByText("/invoice-pool")).toHaveCount(0); await page.screenshot({ path: screenshotPath("invoice-pool-desktop.png"), fullPage: true }); }); test("移动宽度下关键页面仍保持浅色中文工作台风格", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/invoice-pool"); await expect(page.getByRole("heading", { name: "票据" })).toBeVisible(); await expect(page.getByTestId("invoice-pool-mobile-list")).toBeVisible(); await expect(page.getByText("Choose File")).toHaveCount(0); await page.screenshot({ path: screenshotPath("invoice-pool-mobile.png"), fullPage: true }); }); test("采购长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto(`/purchase-requests?id=${purchaseId}`); const title = page.getByTestId("purchase-title"); await expect(title).toBeVisible(); await title.fill("办公用品采购(未保存)"); const returnToList = page.getByRole("button", { name: "返回列表", exact: true }); await returnToList.click(); await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存"); await page.getByTestId("unsaved-changes-continue").click(); await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`)); await expect(title).toHaveValue("办公用品采购(未保存)"); await returnToList.click(); await page.getByTestId("unsaved-changes-discard").click(); await expect(page).toHaveURL(/\/purchase-requests$/); await expect(page.getByTestId("purchase-row-open")).toBeVisible(); await page.goto(`/purchase-requests?id=${purchaseId}`); await page.getByTestId("purchase-title").fill("办公用品采购(已保存)"); await page.getByTestId("purchase-save").click(); await expect(page.getByText("采购草稿已保存")).toBeVisible(); await page.getByRole("link", { name: "票据", exact: true }).first().click(); await expect(page).toHaveURL(/\/invoice-pool$/); await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0); }); test("资产长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto("/assets"); await page.getByTestId("asset-create").click(); const name = page.getByTestId("asset-name"); await expect(name).toBeVisible(); await name.fill("合成未保存资产"); const returnToList = page.getByRole("button", { name: "返回列表", exact: true }); await returnToList.click(); await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存"); await page.getByTestId("unsaved-changes-continue").click(); await expect(page).toHaveURL(/\/assets$/); await expect(name).toHaveValue("合成未保存资产"); await returnToList.click(); await page.getByTestId("unsaved-changes-discard").click(); await expect(page).toHaveURL(/\/assets$/); await expect(page.getByTestId("asset-row")).toBeVisible(); }); test("共享未保存保护覆盖浏览器返回取消、确认与刷新拦截", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.getByRole("link", { name: "采购", exact: true }).first().click(); await expect(page).toHaveURL(/\/purchase-requests$/); await page.getByTestId("purchase-row-open").click(); await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`)); const title = page.getByTestId("purchase-title"); await title.fill("浏览器历史未保存验证"); await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "true"); await page.goBack(); await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存"); await page.getByTestId("unsaved-changes-continue").click(); await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`)); await expect(title).toHaveValue("浏览器历史未保存验证"); const beforeUnloadPromise = page.waitForEvent("dialog"); const reloadPromise = page.reload({ waitUntil: "domcontentloaded", timeout: 10_000 }).catch(() => null); const beforeUnload = await beforeUnloadPromise; expect(beforeUnload.type()).toBe("beforeunload"); await beforeUnload.dismiss(); await reloadPromise; await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`)); await expect(title).toHaveValue("浏览器历史未保存验证"); const acceptedBeforeUnloadPromise = page.waitForEvent("dialog"); const acceptedReloadPromise = page.reload({ waitUntil: "domcontentloaded", timeout: 10_000 }); const acceptedBeforeUnload = await acceptedBeforeUnloadPromise; expect(acceptedBeforeUnload.type()).toBe("beforeunload"); await acceptedBeforeUnload.accept(); await acceptedReloadPromise; await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`)); await expect(title).toHaveValue("办公用品采购"); await title.fill("浏览器历史再次未保存验证"); await page.goBack(); await expect(page.getByTestId("unsaved-changes-dialog")).toBeVisible(); await page.getByTestId("unsaved-changes-discard").click(); // 详情查询参数由列表页使用 replace 写入,因此它的真实上一项是进入采购页前的工作台。 await expect(page).toHaveURL(/\/portal$/); }); test("/projects /assets /reimbursements 在中等宽度下操作列可达且页面不横溢", async ({ page }) => { const targets = [ { path: "/projects", heading: "项目", tableTestId: "projects-table", screenshotName: "projects-768x1024.png" }, { path: "/assets", heading: "资产", tableTestId: "assets-table", screenshotName: "assets-768x1024.png" }, { path: "/reimbursements", heading: "我的报销", tableTestId: "reimbursements-table", screenshotName: "reimbursements-768x1024.png" }, ]; for (const viewport of [ { width: 768, height: 1024 }, { width: 1024, height: 768 }, ]) { await page.setViewportSize(viewport); for (const target of targets) { await expectResponsiveBusinessTable(page, { ...target, screenshotName: target.screenshotName.replace("768x1024", `${viewport.width}x${viewport.height}`), }); } } }); });