文件
hyb-oa/client/tests/e2e/r106-visual-audit.spec.ts
wangsli 1c4633b62a 补齐手机登录页截图就绪检查(09-06-release-gate-integrity)
承接 09-17-company-loan-principal-repayment 的 N3 候选验证。
R106 手机首次截图前确认登录标题、账号输入和按钮已就绪,保留原全页截图、真实隔离后端登录、Worker 设置及全部巡检。

验证:受管完整 R106 文件 1 项通过,零失败/跳过/重试,14 张截图和报告生成;环境、源码一致性、清理、任务与治理检查通过。文档登记保留原有告警。

第四轮完整门禁为 371 通过、1 失败,原回执保留。Chromium 原始错误唯一成因尚未证明,此候选另行执行完整门禁。
独立代码复核、真实还款验收和发布待完成;仅本地提交。
2026-10-09 10:19:57 +08:00

582 行
22 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { mkdirSync, mkdtempSync, 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<string, unknown>;
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<Record<string, unknown>>;
clipped: Array<Record<string, unknown>>;
};
const ROOT_DIR = path.resolve(process.cwd(), "..");
const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(ROOT_DIR, "output/playwright"));
mkdirSync(visualRoot, { recursive: true });
const SCREENSHOT_DIR = mkdtempSync(path.join(visualRoot, "r106-"));
const ARTIFACT_DIR = path.join(SCREENSHOT_DIR, "report");
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<SeededAuditData> {
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<PageMetrics> {
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<Record<string, unknown>> = [];
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<Record<string, unknown>> = [];
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<BrowserContext>) {
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");
// Navigation can finish before the React login view is ready for capture.
await expect(mobilePage.getByRole("heading", { name: "海音达 OA", exact: true })).toBeVisible();
await expect(mobilePage.getByTestId("login-identifier")).toBeVisible();
await expect(mobilePage.getByTestId("login-submit")).toBeEnabled();
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);
});
});