import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; import { expect, test, type Browser, type Page } from "@playwright/test"; import { EMPLOYEE, MANAGER, createAuthenticatedApiClient, apiGetData, apiPostData, type AuthenticatedApiClient, type E2EUser, } from "./helpers/auth"; import { waitForWorkflowTask } from "./helpers/workflow"; type Finding = { severity: "P1" | "P2"; page: string; category: string; title: string; detail: string; evidence?: string; }; type CheckRecord = Record; type MobileAuditOptions = { label: string; route: string; screenshotName: string; skipNavigation?: boolean; }; type MobileMetrics = { text: string; width: number; height: number; scrollWidth: number; visibleTables: Array>; bannedFound: string[]; darkSurfaces: Array>; clipped: Array>; bottomOverlap: Array>; moreMenuRatio: number | null; }; const ROOT_DIR = path.resolve(process.cwd(), ".."); const RUN_DIR = process.env.QA_VISUAL_OUTPUT_DIR ? path.resolve(process.env.QA_VISUAL_OUTPUT_DIR, "r108") : path.join(ROOT_DIR, "artifacts/qa-layout/r108-runs", `${Date.now()}-${process.pid}`); const SCREENSHOT_DIR = path.join(RUN_DIR, "screenshots"); const ARTIFACT_DIR = path.join(RUN_DIR, "reports"); const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url)); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 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.replaceAll(ROOT_DIR, "").replace(UUID_RE, "[uuid]"); } function addFinding(finding: Finding) { findings.push({ ...finding, detail: redact(finding.detail), }); } async function loginForMobile(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 newWecomPage(browser: Browser, viewport: { width: number; height: number }) { const context = await browser.newContext({ viewport, isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN", serviceWorkers: "block", }); return { context, page: await context.newPage() }; } async function syntheticPdf(label: string) { const document = await requireServer("pdf-lib").PDFDocument.create(); document.addPage().drawText(`Synthetic R108 ${label} ${Date.now()}`, { x: 40, y: 700 }); return Buffer.from(await document.save()); } async function seedEmployeeMobileData() { const client = await createAuthenticatedApiClient(EMPLOYEE); try { const expenseDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()); const types = await apiGetData<{ items: Array<{ id: string; code: string; enabled: boolean }> }>(client.api, "/api/expense-types?enabled=true"); const expenseType = types.items.find(item => item.enabled && item.code === "office_postal"); expect(expenseType, "R108 requires the active synthetic postal expense type").toBeDefined(); const reimbursement = await apiPostData<{ id: string }>(client.api, "/api/reimbursements", { description: "R108 移动审批合成报销", reimbursementDate: expenseDate, }); const item = await apiPostData<{ id: string }>(client.api, `/api/reimbursements/${reimbursement.id}/items`, { expenseTypeId: expenseType!.id, expenseDate, amount: "10.00", description: "R108 合成邮寄费", }); const uploaded = await client.api.post("/api/invoice-pool/upload", { multipart: { file: { name: "r108-approval.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("approval") }, ocr_type: "general" }, }); expect(uploaded.ok(), `R108 upload HTTP ${uploaded.status()}`).toBe(true); const invoice = await uploaded.json() as { data: { invoicePoolId: string } }; await expect.poll(async () => (await apiGetData<{ status: string }>(client.api, `/api/invoice-pool/${invoice.data.invoicePoolId}`)).status, { timeout: 90_000 }).toBe("available"); await apiPostData(client.api, `/api/invoice-pool/${invoice.data.invoicePoolId}/bind`, { reimbursementId: reimbursement.id, linkedItemId: item.id }); const submitted = await apiPostData<{ reimbursementId: string; formNumber: string; currentTaskId: string }>(client.api, `/api/reimbursements/${reimbursement.id}/submit`, {}); const denied = await client.api.post(`/api/workflow/tasks/${submitted.currentTaskId}/approve`, { data: {} }); expect(denied.status(), "员工不可审批分配给经理的任务").toBe(403); return submitted; } finally { await client.dispose(); } } async function auditMobilePage(page: Page, options: MobileAuditOptions): Promise { if (!options.skipNavigation) { 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(() => { 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]), }; } function isVisible(element: Element) { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; } 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 visibleTables = Array.from(document.querySelectorAll("table")) .filter(isVisible) .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", "/invoice-pool", "/purchase-requests"]; const bannedFound = bannedTerms.filter((term) => text.includes(term)); const darkSurfaces: Array> = []; for (const element of Array.from(document.querySelectorAll("body *"))) { if (!isVisible(element)) continue; const style = getComputedStyle(element); const rgb = parseRgb(style.backgroundColor); if (!rgb || rgb.a < 0.2) continue; const average = (rgb.r + rgb.g + rgb.b) / 3; const rect = element.getBoundingClientRect(); const area = rect.width * rect.height; if (average < 36 && area > 2200) { 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]"))) { if (!isVisible(element)) continue; if (element.classList.contains("sr-only")) 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}`, }); } } const nav = document.querySelector("[data-testid='mobile-nav-home']")?.closest("nav"); const navRect = nav?.getBoundingClientRect() ?? null; const bottomOverlap: Array> = []; function canScrollDownFrom(element: Element) { let current = element.parentElement; while (current && current !== document.body) { const style = getComputedStyle(current); const canScroll = /(auto|scroll)/.test(style.overflowY) && current.scrollHeight > current.clientHeight + 4; if (canScroll) { return current.scrollTop < current.scrollHeight - current.clientHeight - 4; } current = current.parentElement; } const root = document.scrollingElement ?? document.documentElement; return root.scrollTop < root.scrollHeight - root.clientHeight - 4; } if (navRect) { for (const element of Array.from(document.querySelectorAll("main button, main a, main input, main select, main textarea, main [role='button']"))) { if (!isVisible(element) || nav?.contains(element)) continue; const rect = element.getBoundingClientRect(); const area = rect.width * rect.height; if (area > 300 && rect.bottom > navRect.top + 4 && rect.top < navRect.bottom - 4 && !canScrollDownFrom(element)) { bottomOverlap.push({ tag: element.tagName.toLowerCase(), testid: element.getAttribute("data-testid") ?? "", text: (element.textContent ?? element.getAttribute("aria-label") ?? "").trim().slice(0, 40), rect: `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)}x${Math.round(rect.height)}`, }); } } } const moreLinks = document.querySelector("[data-testid='mobile-more-links']"); const moreRect = moreLinks?.getBoundingClientRect() ?? null; const moreMenuRatio = moreRect ? moreRect.height / window.innerHeight : null; return { text, width, height, scrollWidth, visibleTables, bannedFound, darkSurfaces: darkSurfaces.slice(0, 8), clipped: clipped.slice(0, 12), bottomOverlap: bottomOverlap.slice(0, 8), moreMenuRatio, }; }); const uuids = Array.from(new Set(metrics.text.match(UUID_RE) ?? [])).slice(0, 8); checks.push({ label: options.label, route: options.route, screenshot: shot, width: metrics.width, height: metrics.height, scrollWidth: metrics.scrollWidth, visibleTableCount: metrics.visibleTables.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 (metrics.visibleTables.length > 0) { addFinding({ severity: "P1", page: options.label, category: "移动端", title: "手机视口仍出现桌面表格", detail: JSON.stringify(metrics.visibleTables), 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.darkSurfaces.length > 0) { addFinding({ severity: "P2", page: options.label, category: "视觉", title: "手机页面存在大块近黑背景", detail: JSON.stringify(metrics.darkSurfaces.slice(0, 3)), 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, }); } if (metrics.bottomOverlap.length > 0) { addFinding({ severity: "P2", page: options.label, category: "移动导航", title: "底部导航疑似遮挡主操作", detail: JSON.stringify(metrics.bottomOverlap.slice(0, 4)), evidence: shot, }); } if (metrics.moreMenuRatio != null && metrics.moreMenuRatio > 0.6) { addFinding({ severity: "P2", page: options.label, category: "移动菜单", title: "更多菜单占用过多首屏空间", detail: `ratio=${metrics.moreMenuRatio.toFixed(2)}`, evidence: shot, }); } return metrics; } function writeReport(completed: boolean) { const generatedAt = new Date().toISOString(); const report = { generatedAt, status: completed && findings.length === 0 ? "PASS" : "INCOMPLETE_OR_FAILED", checks, findings }; writeFileSync(artifactPath("report.json"), redact(JSON.stringify(report, null, 2))); const lines = [ "# R108 企业微信移动端视觉巡检报告", "", `生成时间:${generatedAt}`, "", "## 结论", "", `- 完整执行:${completed ? "是" : "否(以浏览器失败为准)"}`, `- 巡检记录数:${checks.length}`, `- 发现问题数:${findings.length}`, `- 截图目录:${path.relative(ROOT_DIR, 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.forEach((item) => { lines.push(`- ${String(item.label)}:${redact(String(item.route))},viewport=${String(item.width)}x${String(item.height)},scrollWidth=${String(item.scrollWidth)}`); }); writeFileSync(artifactPath("report.md"), lines.join("\n")); } test.describe.serial("R108 企业微信移动端视觉专项", () => { test.skip(process.env.E2E_MODE === "live", "R108 本地移动专项只在临时本地环境运行"); test("员工移动页面、票据上传队列与经理移动审批可巡检", async ({ browser }) => { let completed = false; const contexts: Array>["context"]> = []; let managerClient: AuthenticatedApiClient | null = null; try { const reimbursement = await seedEmployeeMobileData(); managerClient = await createAuthenticatedApiClient(MANAGER); const task = await waitForWorkflowTask(managerClient.api, { entityId: reimbursement.reimbursementId, entityType: "reimbursement", }); expect(task.id).toBe(reimbursement.currentTaskId); const employee390 = await newWecomPage(browser, { width: 390, height: 844 }); contexts.push(employee390.context); await loginForMobile(employee390.page, EMPLOYEE); await auditMobilePage(employee390.page, { label: "企微 390-首页", route: "/mobile", screenshotName: "mobile-390-home" }); await auditMobilePage(employee390.page, { label: "企微 390-报销", route: "/mobile/reimbursements", screenshotName: "mobile-390-reimbursements" }); await auditMobilePage(employee390.page, { label: "企微 390-请假", route: "/mobile/leave", screenshotName: "mobile-390-leave" }); await auditMobilePage(employee390.page, { label: "企微 390-我的", route: "/mobile/me", screenshotName: "mobile-390-me" }); await employee390.page.goto("/mobile/capture"); await expect(employee390.page.getByRole("heading", { name: "票据上传", exact: true })).toBeVisible(); await employee390.page.context().setOffline(true); await employee390.page.evaluate(() => window.dispatchEvent(new Event("offline"))); await expect(employee390.page.getByText("离线", { exact: true })).toBeVisible(); await employee390.page.getByTestId("mobile-capture-pdf-file").setInputFiles([ { name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("queue-a") }, { name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("queue-b") }, ]); await expect(employee390.page.getByText("已选择 2 个")).toBeVisible(); await employee390.page.getByTestId("mobile-capture-upload").click(); await expect(employee390.page.getByTestId("mobile-capture-queue")).toContainText("待上传 2 项", { timeout: 15_000 }); await employee390.page.getByRole("button", { name: "移除" }).first().click(); await expect(employee390.page.getByTestId("mobile-capture-queue")).toContainText("待上传 1 项", { timeout: 15_000 }); await auditMobilePage(employee390.page, { label: "企微 390-票据上传队列", route: "/mobile/capture", screenshotName: "mobile-390-capture-queue", skipNavigation: true }); await employee390.page.context().setOffline(false); await employee390.page.evaluate(() => window.dispatchEvent(new Event("online"))); await expect(employee390.page.getByTestId("mobile-capture-queue")).toContainText("无待上传项", { timeout: 30_000 }); await employee390.context.close(); const employee414 = await newWecomPage(browser, { width: 414, height: 896 }); contexts.push(employee414.context); await loginForMobile(employee414.page, EMPLOYEE); await auditMobilePage(employee414.page, { label: "企微 414-首页", route: "/mobile", screenshotName: "mobile-414-home" }); await auditMobilePage(employee414.page, { label: "企微 414-票据上传", route: "/mobile/capture", screenshotName: "mobile-414-capture" }); await employee414.context.close(); const manager390 = await newWecomPage(browser, { width: 390, height: 844 }); contexts.push(manager390.context); await loginForMobile(manager390.page, MANAGER); await manager390.page.goto(`/mobile/approvals?taskId=${encodeURIComponent(task.id)}`); await expect(manager390.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible({ timeout: 30_000 }); await expect(manager390.page.getByTestId("reimbursement-mobile-workspace")).toContainText(reimbursement.formNumber); await expect(manager390.page.getByTestId("mobile-approvals-approve")).toBeEnabled(); await expect(manager390.page.getByTestId("mobile-approvals-reject")).toHaveCount(0); await expect(manager390.page.getByTestId("mobile-approvals-return")).toBeEnabled(); await manager390.page.getByTestId("mobile-approvals-return").click(); await manager390.page.getByTestId("mobile-approvals-comment").fill("R108 移动审批操作区验证"); await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail", skipNavigation: true }); await manager390.page.getByTestId("mobile-approvals-approve").click(); await expect(manager390.page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点"); const approval = manager390.page.waitForResponse(response => new URL(response.url()).pathname === `/api/workflow/tasks/${task.id}/approve` && response.request().method() === "POST"); await manager390.page.getByTestId("workflow-action-confirm-submit").click(); expect((await approval).ok()).toBe(true); const processed = await apiGetData<{ task: { status: string; outcome: string } }>(managerClient.api, `/api/workflow/tasks/${task.id}`); expect(processed.task.status).toBe("completed"); expect(processed.task.outcome).toBe("approved"); await expect(manager390.page.getByText("已通过", { exact: true })).toBeVisible({ timeout: 30_000 }); await manager390.context.close(); { const summary = findings.map((finding) => `${finding.severity} ${finding.page} / ${finding.category}: ${finding.title}`).join("\n"); expect(findings, summary || "R108 mobile visual findings should be empty").toEqual([]); } completed = true; } finally { writeReport(completed); await Promise.all([...contexts.map(context => context.close()), managerClient?.dispose()]); } }); });