485 行
19 KiB
TypeScript
485 行
19 KiB
TypeScript
import { mkdirSync, writeFileSync } from "node:fs";
|
||
import path from "node:path";
|
||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||
|
||
import {
|
||
EMPLOYEE,
|
||
MANAGER,
|
||
createAuthenticatedApiClient,
|
||
type AuthenticatedApiClient,
|
||
type E2EUser,
|
||
} from "./helpers/auth";
|
||
import { createSubmittedTravelRequestViaApi } from "./helpers/travel";
|
||
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 SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r108");
|
||
const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r108-mobile-wecom-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: 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 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 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 seedEmployeeMobileData() {
|
||
const client = await createAuthenticatedApiClient(EMPLOYEE);
|
||
try {
|
||
const expenseTypeId = await firstEnabledExpenseTypeId(client);
|
||
const reimbursementResponse = await client.api.post("/api/reimbursements", {
|
||
data: { description: "R108 移动端巡检报销草稿" },
|
||
});
|
||
const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } };
|
||
if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) {
|
||
throw new Error(`创建移动端报销草稿失败:HTTP ${reimbursementResponse.status()}`);
|
||
}
|
||
|
||
const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, {
|
||
data: {
|
||
expenseTypeId,
|
||
expenseDate: "2026-05-07",
|
||
amount: "88.80",
|
||
description: "R108 移动巡检明细",
|
||
},
|
||
});
|
||
if (!itemResponse.ok()) {
|
||
throw new Error(`创建移动端报销明细失败:HTTP ${itemResponse.status()}`);
|
||
}
|
||
|
||
return { reimbursementId: reimbursementPayload.data.id };
|
||
} 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() {
|
||
const generatedAt = new Date().toISOString();
|
||
const report = { generatedAt, checks, findings };
|
||
writeFileSync(artifactPath("report.json"), redact(JSON.stringify(report, null, 2)));
|
||
|
||
const lines = [
|
||
"# R108 企业微信移动端视觉巡检报告",
|
||
"",
|
||
`生成时间:${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.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 }) => {
|
||
await seedEmployeeMobileData();
|
||
const travel = await createSubmittedTravelRequestViaApi({
|
||
user: EMPLOYEE,
|
||
purposePrefix: "R108移动审批视觉",
|
||
amount: "1098.00",
|
||
});
|
||
const managerClient = await createAuthenticatedApiClient(MANAGER);
|
||
|
||
try {
|
||
const task = await waitForWorkflowTask(managerClient.api, {
|
||
entityId: travel.travelRequest.requestId,
|
||
entityType: "travel_request",
|
||
});
|
||
|
||
const employee390 = await newWecomPage(browser, { width: 390, height: 844 });
|
||
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-file").setInputFiles([
|
||
{ name: "r108-mobile-a.jpg", mimeType: "image/jpeg", buffer: Buffer.from("r108-mobile-a") },
|
||
{ name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
|
||
]);
|
||
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 });
|
||
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 });
|
||
await loginForMobile(manager390.page, MANAGER);
|
||
await manager390.page.goto(`/mobile/approvals?taskId=${encodeURIComponent(task.id)}`);
|
||
await expect(manager390.page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||
await expect(manager390.page.getByTestId("mobile-approvals-detail")).toContainText(travel.travelRequest.requestNo);
|
||
await expect(manager390.page.getByTestId("mobile-approvals-approve")).toBeEnabled();
|
||
await expect(manager390.page.getByTestId("mobile-approvals-reject")).toBeEnabled();
|
||
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" });
|
||
await manager390.page.getByTestId("mobile-approvals-approve").click();
|
||
await expect(manager390.page.getByText("操作成功")).toBeVisible({ timeout: 30_000 });
|
||
await manager390.context.close();
|
||
|
||
writeReport();
|
||
if (process.env.R108_MOBILE_ALLOW_FINDINGS !== "1") {
|
||
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([]);
|
||
}
|
||
} finally {
|
||
await Promise.all([travel.client.dispose(), managerClient.dispose()]);
|
||
}
|
||
});
|
||
});
|