文件
hyb-oa/client/tests/e2e/r108-mobile-wecom-visual.spec.ts
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

497 行
22 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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<string, unknown>;
type MobileAuditOptions = {
label: string;
route: string;
screenshotName: string;
skipNavigation?: boolean;
};
type MobileMetrics = {
text: string;
width: number;
height: number;
scrollWidth: number;
visibleTables: Array<Record<string, unknown>>;
bannedFound: string[];
darkSurfaces: Array<Record<string, unknown>>;
clipped: Array<Record<string, unknown>>;
bottomOverlap: Array<Record<string, unknown>>;
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, "<worktree>").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<MobileMetrics> {
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<Record<string, unknown>> = [];
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<Record<string, unknown>> = [];
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<Record<string, unknown>> = [];
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<Awaited<ReturnType<typeof newWecomPage>>["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()]);
}
});
});