import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { expect, test, type Browser, type Page } from "@playwright/test"; import { ADMIN, EMPLOYEE, MANAGER, createAuthenticatedApiClient, type E2EUser } from "./helpers/auth"; type MatrixRow = { Domain: string; Tier: string; PC_Routes: string; Mobile_WeCom_Routes: string; }; type VisualFinding = { severity: "P1" | "P2"; route: string; domain: string; viewport: "pc" | "mobile-wecom"; category: string; detail: string; screenshot?: string; }; type PreparedRoutes = { reimbursementId: string; projectId: string | null; workflowTaskId: string | null; employeeId: string; }; const ROOT_DIR = path.resolve(process.cwd(), ".."); const MATRIX_PATH = path.join(ROOT_DIR, "docs/trialrun/project-wide-flow-matrix.csv"); const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/project-wide-visual"); const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/qa-flow/project-wide-visual"); 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: VisualFinding[] = []; function outputPath(dir: string, fileName: string) { mkdirSync(dir, { recursive: true }); return path.join(dir, fileName); } function parseCsvLine(line: string) { const cells: string[] = []; let current = ""; let quoted = false; for (let index = 0; index < line.length; index += 1) { const char = line[index]; const next = line[index + 1]; if (quoted && char === "\"" && next === "\"") { current += "\""; index += 1; continue; } if (char === "\"") { quoted = !quoted; continue; } if (!quoted && char === ",") { cells.push(current); current = ""; continue; } current += char; } cells.push(current); return cells; } function parseMatrix() { const lines = readFileSync(MATRIX_PATH, "utf8").split(/\r?\n/).filter((line) => line.trim()); const header = parseCsvLine(lines[0] ?? ""); return lines.slice(1).map((line) => { const cells = parseCsvLine(line); return Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""])) as MatrixRow; }); } function splitRoutes(value: string) { return value.split("|").map((item) => item.trim()).filter(Boolean); } function sanitizeRoute(route: string) { return route.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root"; } function addFinding(finding: VisualFinding) { findings.push({ ...finding, detail: finding.detail.replace(UUID_RE, "[uuid]"), }); } function replaceDynamicRoute(route: string, prepared: PreparedRoutes) { if (route.includes(":reimbursementId")) return route.replace(":reimbursementId", prepared.reimbursementId); if (route.includes("/reimbursements/:id")) return route.replace(":id", prepared.reimbursementId); if (route.includes(":projectId")) return prepared.projectId ? route.replace(":projectId", prepared.projectId) : null; if (route.includes(":taskId")) return prepared.workflowTaskId ? route.replace(":taskId", prepared.workflowTaskId) : null; if (route.includes(":employeeId")) return route.replace(":employeeId", prepared.employeeId); return route; } async function loginForVisual(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 newWecomContext(browser: Browser) { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN", serviceWorkers: "block", }); return context; } async function prepareRoutes() { const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); const managerClient = await createAuthenticatedApiClient(MANAGER); const adminClient = await createAuthenticatedApiClient(ADMIN); try { const created = await employeeClient.api.post("/api/reimbursements", { data: { description: "项目级视觉巡检草稿" }, }); const createdJson = await created.json() as { data?: { id?: string } }; if (!created.ok() || !createdJson.data?.id) { throw new Error(`创建视觉巡检报销草稿失败:HTTP ${created.status()}`); } const projectsResponse = await adminClient.api.get("/api/projects?page=1&pageSize=1"); const projectsJson = await projectsResponse.json().catch(() => null) as { data?: { items?: Array<{ id: string }> } } | null; const projectId = projectsResponse.ok() ? projectsJson?.data?.items?.[0]?.id ?? null : null; const tasksResponse = await managerClient.api.get("/api/workflow/tasks?status=pending&page=1&pageSize=1"); const tasksJson = await tasksResponse.json().catch(() => null) as { data?: { items?: Array<{ id: string }> } } | null; const workflowTaskId = tasksResponse.ok() ? tasksJson?.data?.items?.[0]?.id ?? null : null; return { reimbursementId: createdJson.data.id, projectId, workflowTaskId, employeeId: employeeClient.me.id, }; } finally { await Promise.all([employeeClient.dispose(), managerClient.dispose(), adminClient.dispose()]); } } function userForRoute(route: string): E2EUser { if (route.startsWith("/mobile/approvals") || route.startsWith("/workflow/tasks")) return MANAGER; if ( route.startsWith("/system") || route.startsWith("/people") || route.startsWith("/organization") || route.startsWith("/audit-logs") || route.startsWith("/payroll") || route.startsWith("/performance") ) { return ADMIN; } return EMPLOYEE; } async function auditPage(page: Page, input: { route: string; domain: string; viewport: "pc" | "mobile-wecom"; }) { const responses: Array<{ status: number; url: string }> = []; const consoleErrors: string[] = []; page.on("response", (response) => { if (response.status() >= 500) { responses.push({ status: response.status(), url: response.url() }); } }); page.on("console", (message) => { if (message.type() === "error") { const text = message.text(); if (!/Failed to load resource: the server responded with a status of 4\d\d/i.test(text)) { consoleErrors.push(text); } } }); await page.goto(input.route); await page.waitForLoadState("networkidle").catch(() => undefined); await page.waitForTimeout(300); const screenshot = outputPath( SCREENSHOT_DIR, `${input.viewport}-${sanitizeRoute(input.route)}.png`, ); await page.screenshot({ path: screenshot, fullPage: true }); const metrics = await page.evaluate(() => { const text = document.body?.innerText ?? ""; const root = document.documentElement; const width = window.innerWidth; const scrollWidth = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0); const visibleTables = Array.from(document.querySelectorAll("table")).filter((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; }).length; const sidebarLike = Array.from(document.querySelectorAll("aside, nav")).filter((element) => { const rect = element.getBoundingClientRect(); const textContent = element.textContent ?? ""; return rect.width > 160 && textContent.includes("备用模块"); }).length; const clipped = Array.from(document.querySelectorAll("button,a,[role='button'],select,input,textarea,[data-testid]")) .filter((element) => { const rect = element.getBoundingClientRect(); if (rect.width < 8 || rect.height < 8) return false; const style = getComputedStyle(element); const overflowHidden = ["hidden", "clip"].includes(style.overflowX) || ["hidden", "clip"].includes(style.overflowY); return overflowHidden && (element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2); }) .slice(0, 8) .map((element) => ({ tag: element.tagName.toLowerCase(), testid: element.getAttribute("data-testid") ?? "", text: (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("placeholder") ?? "").trim().slice(0, 60), })); return { text, width, scrollWidth, visibleTables, sidebarLike, clipped }; }); if (responses.length) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "http", detail: JSON.stringify(responses.slice(0, 5)), screenshot, }); } if (consoleErrors.length) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "console", detail: consoleErrors.slice(0, 5).join("\n"), screenshot, }); } if (metrics.scrollWidth > metrics.width + 2) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "responsive", detail: `viewport=${metrics.width}, scrollWidth=${metrics.scrollWidth}`, screenshot, }); } if (input.viewport === "mobile-wecom") { if (metrics.visibleTables > 0) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "mobile-table", detail: `移动页存在可见 table:${metrics.visibleTables}`, screenshot, }); } if (metrics.sidebarLike > 0) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "mobile-pc-shell", detail: "企业微信移动端出现 PC 导航壳。", screenshot, }); } } const rawCodePatterns = [/\bdraft\b/i, /\bbound\b/i, /business_client_meal/i, /expense-type-[a-z0-9-]+/i]; const shouldApplyOrdinaryUserCopyGate = ( input.route.startsWith("/reimbursements") || input.route.startsWith("/mobile/reimbursements") || input.route.startsWith("/mobile/capture") ); const bannedTerms = ["绑定整单", "不绑定明细", "发票池"]; if (shouldApplyOrdinaryUserCopyGate && (rawCodePatterns.some((pattern) => pattern.test(metrics.text)) || bannedTerms.some((term) => metrics.text.includes(term)))) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "copy", detail: "普通用户页面出现原始码值或内部术语。", screenshot, }); } if (metrics.clipped.length) { addFinding({ severity: "P2", route: input.route, domain: input.domain, viewport: input.viewport, category: "clipped", detail: JSON.stringify(metrics.clipped), screenshot, }); } } function buildRoutePlans(matrix: MatrixRow[], prepared: PreparedRoutes, viewport: "pc" | "mobile-wecom") { const seen = new Set(); const plans: Array<{ route: string; domain: string }> = []; for (const row of matrix) { const tokens = splitRoutes(viewport === "pc" ? row.PC_Routes : row.Mobile_WeCom_Routes); for (const token of tokens) { const route = replaceDynamicRoute(token, prepared); if (!route || seen.has(route)) continue; seen.add(route); plans.push({ route, domain: row.Domain }); } } return plans; } async function auditPlans(browser: Browser, plans: Array<{ route: string; domain: string }>, viewport: "pc" | "mobile-wecom") { const plansByUser = new Map }>(); for (const plan of plans) { const user = userForRoute(plan.route); const entry = plansByUser.get(user.identifier) ?? { user, plans: [] }; entry.plans.push(plan); plansByUser.set(user.identifier, entry); } for (const { user, plans: userPlans } of plansByUser.values()) { const context = viewport === "mobile-wecom" ? await newWecomContext(browser) : await browser.newContext({ viewport: { width: 1366, height: 900 }, serviceWorkers: "block" }); try { const page = await context.newPage(); await loginForVisual(page, user); for (const plan of userPlans) { await auditPage(page, { ...plan, viewport }); } } finally { await context.close(); } } } test.describe("项目级全页面视觉巡检", () => { test("PC 与企业微信移动端没有已知视觉和文案回归", async ({ browser }) => { const prepared = await prepareRoutes(); const matrix = parseMatrix(); const pcPlans = buildRoutePlans(matrix, prepared, "pc"); const mobilePlans = buildRoutePlans(matrix, prepared, "mobile-wecom"); await auditPlans(browser, pcPlans, "pc"); await auditPlans(browser, mobilePlans, "mobile-wecom"); const reportPath = outputPath(ARTIFACT_DIR, "report.json"); writeFileSync(reportPath, JSON.stringify({ checkedAt: new Date().toISOString(), pcRoutes: pcPlans.map((item) => item.route), mobileRoutes: mobilePlans.map((item) => item.route), findings, }, null, 2), "utf8"); expect(findings.filter((finding) => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath}`).toEqual([]); }); });