import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { expect, test, type BrowserContext, type Locator, type Page } from "@playwright/test"; import { ADMIN, EMPLOYEE, createAuthenticatedApiClient, type AuthenticatedApiClient, type E2EUser } from "./helpers/auth"; type Finding = { severity: "P1" | "P2"; page: string; category: string; title: string; detail: string; evidence?: string; }; type CheckRecord = Record; type SeededAuditData = { reimbursementId: string; reimbursementItemId: string; invoicePoolId: string | null; invoiceUploadStatus: string | null; }; type PageAuditOptions = { label: string; route: string; mobile?: boolean; screenshotName: string; }; type PageMetrics = { text: string; width: number; height: number; scrollWidth: number; tables: Array<{ testid: string; width: number; height: number }>; bannedFound: string[]; verboseLines: string[]; darkSurfaces: Array>; clipped: Array>; }; const ROOT_DIR = path.resolve(process.cwd(), ".."); const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r106"); const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r106-visual-audit"); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.0 MicroMessenger/8.0.37 Language/zh_CN"; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; const findings: Finding[] = []; const checks: CheckRecord[] = []; function screenshotPath(fileName: string) { mkdirSync(SCREENSHOT_DIR, { recursive: true }); return path.join(SCREENSHOT_DIR, `${fileName}.png`); } function artifactPath(fileName: string) { mkdirSync(ARTIFACT_DIR, { recursive: true }); return path.join(ARTIFACT_DIR, fileName); } function redact(value: string) { return value.replace(UUID_RE, "[uuid]"); } function addFinding(finding: Finding) { findings.push({ ...finding, detail: redact(finding.detail), }); } async function loginForAudit(page: Page, user: E2EUser) { await page.goto("/login"); await expect(page.getByTestId("login-identifier")).toBeVisible(); await page.getByTestId("login-identifier").fill(user.identifier); await page.getByTestId("login-password").fill(user.password); await page.getByTestId("login-submit").click(); await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 }); await page.waitForLoadState("networkidle").catch(() => undefined); } async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) { const response = await client.api.get("/api/expense-types?enabled=true"); const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }>; }; }; const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0]; if (!response.ok() || !item?.id) { throw new Error(`无法读取可用费用类型:HTTP ${response.status()}`); } return item.id; } async function seedAuditData(): Promise { const client = await createAuthenticatedApiClient(EMPLOYEE); try { const expenseTypeId = await firstEnabledExpenseTypeId(client); const reimbursementResponse = await client.api.post("/api/reimbursements", { data: { description: "R106 视觉自动化巡检草稿" }, }); const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } }; if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) { throw new Error(`创建报销草稿失败:HTTP ${reimbursementResponse.status()}`); } const reimbursementId = reimbursementPayload.data.id; const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementId}/items`, { data: { expenseTypeId, expenseDate: "2026-05-07", amount: "12.30", description: "R106 视觉巡检明细", }, }); const itemPayload = await itemResponse.json() as { data?: { item?: { id?: string } } }; if (!itemResponse.ok() || !itemPayload.data?.item?.id) { throw new Error(`创建报销明细失败:HTTP ${itemResponse.status()}`); } const pdfBuffer = Buffer.from("%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer <<>>\n%%EOF\n"); const uploadResponse = await client.api.post("/api/invoice-pool/upload", { multipart: { file: { name: "r106-visual-audit-invoice.pdf", mimeType: "application/pdf", buffer: pdfBuffer, }, ocr_type: "vat", }, }); const uploadPayload = await uploadResponse.json().catch(() => null) as { data?: { invoicePoolId?: string; status?: string }; error?: { code?: string; message?: string; details?: unknown }; } | null; if (!uploadResponse.ok() || !uploadPayload?.data?.invoicePoolId) { addFinding({ severity: "P1", page: "API /invoice-pool/upload", category: "票据上传", title: "R106 视觉巡检前置票据上传失败", detail: JSON.stringify(uploadPayload ?? { status: uploadResponse.status() }), }); return { reimbursementId, reimbursementItemId: itemPayload.data.item.id, invoicePoolId: null, invoiceUploadStatus: null, }; } return { reimbursementId, reimbursementItemId: itemPayload.data.item.id, invoicePoolId: uploadPayload.data.invoicePoolId, invoiceUploadStatus: uploadPayload.data.status ?? null, }; } finally { await client.dispose(); } } async function auditPage(page: Page, options: PageAuditOptions): Promise { await page.goto(options.route); await page.waitForLoadState("networkidle").catch(() => undefined); await page.waitForTimeout(500); const shot = screenshotPath(options.screenshotName); await page.screenshot({ path: shot, fullPage: true }); const metrics = await page.evaluate(() => { const text = document.body?.innerText ?? ""; const documentElement = document.documentElement; const width = window.innerWidth; const height = window.innerHeight; const scrollWidth = Math.max(documentElement.scrollWidth, document.body?.scrollWidth ?? 0); const tables = Array.from(document.querySelectorAll("table")).map((element) => { const rect = element.getBoundingClientRect(); return { testid: element.getAttribute("data-testid") ?? "", width: Math.round(rect.width), height: Math.round(rect.height), }; }); const bannedTerms = ["先处理", "清爽", "低频模块放在这里", "Choose File"]; const bannedFound = bannedTerms.filter((term) => text.includes(term)); const verboseLines = Array.from( new Set( text .split("\n") .map((line) => line.trim()) .filter((line) => line.length >= 32 && /支持|用于|等待|默认|当前页面|先填写|流程|自动|可在/.test(line)), ), ).slice(0, 12); const darkSurfaces: Array> = []; for (const element of Array.from(document.querySelectorAll("body *"))) { const style = getComputedStyle(element); const match = style.backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); if (!match) continue; const alpha = match[4] == null ? 1 : Number(match[4]); if (alpha < 0.2) continue; const average = (Number(match[1]) + Number(match[2]) + Number(match[3])) / 3; const rect = element.getBoundingClientRect(); const area = rect.width * rect.height; if (average < 36 && area > 2500) { darkSurfaces.push({ tag: element.tagName.toLowerCase(), testid: element.getAttribute("data-testid") ?? "", text: (element.textContent ?? "").trim().slice(0, 40), backgroundColor: style.backgroundColor, area: Math.round(area), }); } } const clipped: Array> = []; for (const element of Array.from(document.querySelectorAll("button,a,[role='button'],select,input,textarea,[data-testid]"))) { const rect = element.getBoundingClientRect(); if (rect.width < 8 || rect.height < 8) continue; const style = getComputedStyle(element); const overflowHidden = ["hidden", "clip"].includes(style.overflowX) || ["hidden", "clip"].includes(style.overflowY); const isClipped = element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2; if (isClipped && overflowHidden) { clipped.push({ tag: element.tagName.toLowerCase(), testid: element.getAttribute("data-testid") ?? "", text: (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("placeholder") ?? "").trim().slice(0, 60), client: `${element.clientWidth}x${element.clientHeight}`, scroll: `${element.scrollWidth}x${element.scrollHeight}`, }); } } return { text, width, height, scrollWidth, tables, bannedFound, verboseLines, darkSurfaces: darkSurfaces.slice(0, 12), clipped: clipped.slice(0, 20) }; }); const uuids = Array.from(new Set(metrics.text.match(UUID_RE) ?? [])).slice(0, 12); checks.push({ label: options.label, route: options.route, mobile: Boolean(options.mobile), screenshot: shot, width: metrics.width, height: metrics.height, scrollWidth: metrics.scrollWidth, tableCount: metrics.tables.length, uuidCount: uuids.length, }); if (metrics.scrollWidth > metrics.width + 2) { addFinding({ severity: "P1", page: options.label, category: "响应式", title: "页面存在横向溢出", detail: `viewport=${metrics.width}px, scrollWidth=${metrics.scrollWidth}px`, evidence: shot, }); } if (options.mobile && metrics.tables.length > 0) { addFinding({ severity: "P1", page: options.label, category: "移动端", title: "手机视口仍出现桌面表格", detail: JSON.stringify(metrics.tables), evidence: shot, }); } if (options.mobile && metrics.darkSurfaces.length > 0) { addFinding({ severity: "P2", page: options.label, category: "视觉", title: "手机页面存在大块近黑背景", detail: JSON.stringify(metrics.darkSurfaces.slice(0, 3)), evidence: shot, }); } if (metrics.bannedFound.length > 0) { addFinding({ severity: "P1", page: options.label, category: "文案", title: "出现设计规则禁用文案", detail: metrics.bannedFound.join("、"), evidence: shot, }); } if (uuids.length > 0) { addFinding({ severity: "P1", page: options.label, category: "业务展示", title: "页面向用户暴露内部 UUID", detail: `${uuids.length} 个 UUID`, evidence: shot, }); } if (metrics.verboseLines.length > 0) { addFinding({ severity: "P2", page: options.label, category: "文案", title: "页面仍有偏解释型/啰嗦文案", detail: metrics.verboseLines.join(" | "), evidence: shot, }); } if (metrics.clipped.length > 0) { addFinding({ severity: "P2", page: options.label, category: "视觉", title: "存在疑似文字/控件裁切", detail: JSON.stringify(metrics.clipped.slice(0, 5)), evidence: shot, }); } return metrics; } async function readButton(locator: Locator) { return locator.evaluate((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { text: (element.textContent ?? "").trim().replace(/\s+/g, " "), rect: { width: Math.round(rect.width), height: Math.round(rect.height), x: Math.round(rect.x), y: Math.round(rect.y) }, style: { backgroundColor: style.backgroundColor, color: style.color, border: style.border, borderRadius: style.borderRadius, fontSize: style.fontSize, fontWeight: style.fontWeight, padding: `${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft}`, }, }; }); } async function compareButtons(page: Page, pageLabel: string, testIdA: string, labelA: string, testIdB: string, labelB: string) { const first = page.getByTestId(testIdA).first(); const second = page.getByTestId(testIdB).first(); if (!(await first.isVisible().catch(() => false)) || !(await second.isVisible().catch(() => false))) { checks.push({ label: pageLabel, type: "button-compare", status: "skipped", testIdA, testIdB }); return; } const a = await readButton(first); const b = await readButton(second); const diffs: string[] = []; if (Math.abs(a.rect.height - b.rect.height) > 2) diffs.push(`高度 ${a.rect.height}/${b.rect.height}`); if (a.style.borderRadius !== b.style.borderRadius) diffs.push(`圆角 ${a.style.borderRadius}/${b.style.borderRadius}`); if (a.style.fontSize !== b.style.fontSize) diffs.push(`字号 ${a.style.fontSize}/${b.style.fontSize}`); if (a.style.padding !== b.style.padding) diffs.push(`内边距 ${a.style.padding}/${b.style.padding}`); if (Math.abs(a.rect.y - b.rect.y) > 3) diffs.push(`纵向位置 ${a.rect.y}/${b.rect.y}`); checks.push({ label: pageLabel, type: "button-compare", status: diffs.length ? "diff" : "same-layout", labelA, labelB, data: { a, b } }); if (diffs.length) { addFinding({ severity: "P2", page: pageLabel, category: "按钮一致性", title: `${labelA} 与 ${labelB} 视觉规格不一致`, detail: diffs.join(";"), }); } } async function detectDangerButtonStyle(page: Page, pageLabel: string, screenshot: string) { const mismatches = await page.evaluate(() => { function parseRgb(value: string) { const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); if (!match) return null; return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: match[4] == null ? 1 : Number(match[4]), }; } return Array.from(document.querySelectorAll("button,[role='button'],a")) .map((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); const color = parseRgb(style.color); const background = parseRgb(style.backgroundColor); return { text: (element.textContent ?? "").trim().replace(/\s+/g, " "), testid: element.getAttribute("data-testid") ?? "", color: style.color, backgroundColor: style.backgroundColor, visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none", redTextOnPrimary: Boolean( color && background && color.r > 180 && color.g < 90 && color.b < 90 && background.b > 80 && background.r < 40 && background.g < 100 ), }; }) .filter((item) => item.visible && item.redTextOnPrimary); }); if (mismatches.length) { addFinding({ severity: "P2", page: pageLabel, category: "按钮样式", title: "危险/终态操作出现主色背景叠红色文字", detail: JSON.stringify(mismatches), evidence: screenshot, }); } } async function auditMobileMoreMenu(page: Page) { const moreMetrics = await page.evaluate(() => { const element = document.querySelector("[data-testid='mobile-more-links']"); if (!element) return null; const rect = element.getBoundingClientRect(); return { height: Math.round(rect.height), viewport: window.innerHeight, ratio: rect.height / window.innerHeight, links: element.querySelectorAll("a,button,[role='button']").length, }; }); if (moreMetrics && moreMetrics.ratio > 0.55) { addFinding({ severity: "P2", page: "企微模拟-我的", category: "移动菜单", title: "更多菜单占用过多首屏空间", detail: JSON.stringify(moreMetrics), evidence: screenshotPath("mobile-wecom-me"), }); } } async function newMobileWecomContext(browserContextFactory: () => Promise) { return browserContextFactory(); } function writeReport(seedData: SeededAuditData) { const generatedAt = new Date().toISOString(); const report = { generatedAt, checks, findings, seedData: { reimbursementId: "[redacted-local-id]", reimbursementItemId: "[redacted-local-id]", invoicePoolId: seedData.invoicePoolId ? "[redacted-local-id]" : null, invoiceUploadStatus: seedData.invoiceUploadStatus, }, }; writeFileSync(artifactPath("report.json"), redact(JSON.stringify(report, null, 2))); const lines = [ "# R106 自动视觉巡检报告", "", `生成时间:${generatedAt}`, "", "## 结论", "", `- 巡检记录数:${checks.length}`, `- 发现问题数:${findings.length}`, `- 截图目录:${SCREENSHOT_DIR}`, "", "## 发现的问题", "", ]; if (!findings.length) { lines.push("- 未发现规则命中的视觉/文案/展示问题。"); } else { findings.forEach((finding, index) => { lines.push(`${index + 1}. [${finding.severity}] ${finding.page} / ${finding.category}:${finding.title}`); lines.push(` - 详情:${finding.detail.slice(0, 600)}`); if (finding.evidence) lines.push(` - 截图:${finding.evidence}`); }); } lines.push("", "## 按钮对比", ""); checks .filter((item) => item.type === "button-compare") .forEach((item) => { lines.push(`- ${String(item.label)}:${String(item.labelA ?? item.testIdA)} vs ${String(item.labelB ?? item.testIdB)} => ${String(item.status)}`); }); writeFileSync(artifactPath("report.md"), lines.join("\n")); } test.describe.serial("R106 自动视觉巡检", () => { test.skip(process.env.E2E_MODE === "live", "R106 视觉巡检只在本地临时环境运行"); test("PC 与企微模拟手机页面输出视觉问题报告", async ({ browser }) => { const seedData = await seedAuditData(); const desktop = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); const desktopPage = await desktop.newPage(); await loginForAudit(desktopPage, EMPLOYEE); await auditPage(desktopPage, { label: "PC Portal", route: "/portal", screenshotName: "pc-portal" }); await auditPage(desktopPage, { label: "PC 报销列表", route: "/reimbursements", screenshotName: "pc-reimbursements-list" }); await auditPage(desktopPage, { label: "PC 报销详情", route: `/reimbursements/${seedData.reimbursementId}`, screenshotName: "pc-reimbursement-detail" }); await compareButtons(desktopPage, "PC 报销详情", "reimbursement-item-edit", "明细编辑", "reimbursement-item-delete", "明细删除"); await compareButtons(desktopPage, "PC 报销详情", "reimbursement-back", "返回列表", "reimbursement-delete-draft", "删除草稿"); const invoiceMetrics = await auditPage(desktopPage, { label: "PC 票据", route: "/invoice-pool", screenshotName: "pc-invoice-pool" }); if (/票据\s*ID/.test(invoiceMetrics.text)) { addFinding({ severity: "P2", page: "PC 票据", category: "业务展示", title: "票据详情仍把内部票据 ID 放在主信息区", detail: "票据详情存在“票据 ID”字段,应改为票据号码/销售方/金额等业务信息。", evidence: screenshotPath("pc-invoice-pool"), }); } await detectDangerButtonStyle(desktopPage, "PC 票据", screenshotPath("pc-invoice-pool")); await auditPage(desktopPage, { label: "PC 请假", route: "/leave", screenshotName: "pc-leave" }); await auditPage(desktopPage, { label: "PC 采购", route: "/purchase-requests", screenshotName: "pc-purchase" }); await auditPage(desktopPage, { label: "PC 差旅", route: "/travel-requests", screenshotName: "pc-travel" }); await desktop.close(); const mobileContext = await newMobileWecomContext(() => browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN", })); const mobilePage = await mobileContext.newPage(); await mobilePage.goto("/login"); await mobilePage.screenshot({ path: screenshotPath("wecom-login-before-auth"), fullPage: true }); await loginForAudit(mobilePage, EMPLOYEE); await auditPage(mobilePage, { label: "企微模拟-首页", route: "/mobile", mobile: true, screenshotName: "mobile-wecom-home" }); await auditPage(mobilePage, { label: "企微模拟-报销", route: "/mobile/reimbursements", mobile: true, screenshotName: "mobile-wecom-reimbursements" }); await auditPage(mobilePage, { label: "企微模拟-请假", route: "/mobile/leave", mobile: true, screenshotName: "mobile-wecom-leave" }); await auditPage(mobilePage, { label: "企微模拟-票据上传", route: "/mobile/capture", mobile: true, screenshotName: "mobile-wecom-capture" }); await auditPage(mobilePage, { label: "企微模拟-我的", route: "/mobile/me", mobile: true, screenshotName: "mobile-wecom-me" }); await auditMobileMoreMenu(mobilePage); await mobileContext.close(); const adminMobileContext = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN", }); const adminMobilePage = await adminMobileContext.newPage(); await loginForAudit(adminMobilePage, ADMIN); await auditPage(adminMobilePage, { label: "企微模拟-审批", route: "/mobile/approvals", mobile: true, screenshotName: "mobile-wecom-approvals-admin" }); await adminMobileContext.close(); writeReport(seedData); if (process.env.R106_VISUAL_FAIL_ON_FINDINGS === "1") { const summary = findings.map((finding) => `${finding.severity} ${finding.page} / ${finding.category}: ${finding.title}`).join("\n"); expect(findings, summary || "R106 visual findings should be empty").toEqual([]); } await expect.poll(() => findings.length, { message: "R106 report generated" }).toBeGreaterThanOrEqual(0); }); });