文件
hyb-oa/client/tests/e2e/project-wide-visual.spec.ts
T

394 行
14 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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<string>();
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<string, { user: E2EUser; plans: Array<{ route: string; domain: string }> }>();
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([]);
});
});