OAFRONT3 补足PC只读视觉证据

这个提交包含在:
2026-05-25 00:40:33 +08:00
父节点 c1ca0ea485
当前提交 111f007c02
共修改 3 个文件,包含 562 行新增和 8 行删除
@@ -0,0 +1,485 @@
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { test, type Page } from "@playwright/test";
type RoleKey = "employee" | "manager" | "finance" | "finance_mgr" | "admin" | "ceo" | "hr";
type RouteExpectation = "accessible" | "forbidden_ok" | "optional";
type PageLayer = "A" | "B" | "C";
type RoutePlan = {
role: RoleKey;
route: string;
label: string;
layer: PageLayer;
expectation: RouteExpectation;
};
type RoleResult = {
role: RoleKey;
login: "PASS" | "SKIP" | "FAIL";
reason?: string;
};
type RouteResult = {
role: RoleKey;
route: string;
label: string;
layer: PageLayer;
expectation: RouteExpectation;
status: "PASS" | "FORBIDDEN_EXPECTED" | "FINDING" | "SKIP";
finalPath?: string;
screenshot?: string;
metrics?: Record<string, unknown>;
};
type Finding = {
severity: "P1" | "P2";
role: RoleKey;
route: string;
label: string;
category: string;
detail: string;
screenshot?: string;
};
const ROOT_DIR = path.resolve(process.cwd(), "..");
const REPORT_DIR = resolveRootPath(
process.env.OAFRONT3_PC_REPORT_DIR,
path.join(ROOT_DIR, "artifacts/qa-flow/oafront3-pc-readonly-visual"),
);
const SCREENSHOT_DIR = resolveRootPath(
process.env.OAFRONT3_PC_OUTPUT_DIR,
path.join(ROOT_DIR, "output/playwright/oafront3-pc-readonly"),
);
const BASE_URL = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
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 EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
const LONG_NUMBER_RE = /\b20[0-9]{12,}\b/g;
const ENGLISH_PRIMARY_TERMS = [
"Portal",
"Employee",
"Manager",
"Admin",
"Finance Manager",
"Choose File",
"mm/dd/yyyy",
];
const ROLE_ENVS: Record<RoleKey, { identifier: string; password: string }> = {
employee: { identifier: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", password: "LIVE_SMOKE_EMPLOYEE_PASSWORD" },
manager: { identifier: "LIVE_SMOKE_MANAGER_IDENTIFIER", password: "LIVE_SMOKE_MANAGER_PASSWORD" },
finance: { identifier: "LIVE_SMOKE_FINANCE_IDENTIFIER", password: "LIVE_SMOKE_FINANCE_PASSWORD" },
finance_mgr: { identifier: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", password: "LIVE_SMOKE_FINANCE_MGR_PASSWORD" },
admin: { identifier: "LIVE_SMOKE_ADMIN_IDENTIFIER", password: "LIVE_SMOKE_ADMIN_PASSWORD" },
ceo: { identifier: "LIVE_SMOKE_CEO_IDENTIFIER", password: "LIVE_SMOKE_CEO_PASSWORD" },
hr: { identifier: "LIVE_SMOKE_HR_IDENTIFIER", password: "LIVE_SMOKE_HR_PASSWORD" },
};
const ROUTE_PLANS: RoutePlan[] = [
{ role: "employee", route: "/portal", label: "员工工作台", layer: "A", expectation: "accessible" },
{ role: "employee", route: "/reimbursements", label: "员工报销列表", layer: "A", expectation: "accessible" },
{ role: "employee", route: "/invoice-pool", label: "员工票据中心", layer: "B", expectation: "accessible" },
{ role: "employee", route: "/purchase-requests", label: "员工采购申请", layer: "B", expectation: "accessible" },
{ role: "employee", route: "/assets", label: "员工资产台账", layer: "B", expectation: "accessible" },
{ role: "employee", route: "/payment-batches", label: "员工付款权限负例", layer: "B", expectation: "forbidden_ok" },
{ role: "manager", route: "/portal", label: "主管工作台", layer: "A", expectation: "accessible" },
{ role: "manager", route: "/workflow/tasks", label: "主管审批待办", layer: "A", expectation: "accessible" },
{ role: "manager", route: "/purchase-requests", label: "主管采购申请", layer: "B", expectation: "accessible" },
{ role: "manager", route: "/projects", label: "主管项目列表", layer: "B", expectation: "optional" },
{ role: "finance", route: "/portal", label: "财务工作台", layer: "A", expectation: "accessible" },
{ role: "finance", route: "/invoice-pool", label: "财务票据中心", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/payment-batches", label: "财务付款批次", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/ap-operations", label: "财务 AP 运营", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/reimbursements", label: "财务报销列表", layer: "A", expectation: "accessible" },
{ role: "finance_mgr", route: "/portal", label: "财务负责人工作台", layer: "A", expectation: "accessible" },
{ role: "finance_mgr", route: "/workflow/tasks", label: "财务负责人审批待办", layer: "A", expectation: "accessible" },
{ role: "finance_mgr", route: "/payment-batches", label: "财务负责人付款批次", layer: "B", expectation: "accessible" },
{ role: "finance_mgr", route: "/ap-operations", label: "财务负责人 AP 运营", layer: "B", expectation: "accessible" },
{ role: "finance_mgr", route: "/audit-logs", label: "财务负责人审计日志", layer: "C", expectation: "accessible" },
{ role: "admin", route: "/portal", label: "管理员工作台", layer: "A", expectation: "accessible" },
{ role: "admin", route: "/assets", label: "管理员资产台账", layer: "B", expectation: "accessible" },
{ role: "admin", route: "/contracts-registry", label: "管理员合同台账", layer: "B", expectation: "accessible" },
{ role: "admin", route: "/system/workspace", label: "管理员工作区设置", layer: "C", expectation: "accessible" },
{ role: "admin", route: "/system/approval-rules", label: "管理员审批规则", layer: "C", expectation: "accessible" },
{ role: "admin", route: "/system/feature-flags", label: "管理员功能开关", layer: "C", expectation: "accessible" },
{ role: "ceo", route: "/portal", label: "负责人工作台", layer: "A", expectation: "accessible" },
{ role: "ceo", route: "/workflow/tasks", label: "负责人审批待办", layer: "A", expectation: "accessible" },
{ role: "ceo", route: "/budgets", label: "负责人预算视图", layer: "B", expectation: "optional" },
{ role: "hr", route: "/portal", label: "HR 工作台", layer: "A", expectation: "accessible" },
{ role: "hr", route: "/people", label: "HR 人员管理", layer: "C", expectation: "accessible" },
{ role: "hr", route: "/wecom-approval-archive", label: "HR 企微归档", layer: "B", expectation: "accessible" },
];
const roleResults: RoleResult[] = [];
const routeResults: RouteResult[] = [];
const findings: Finding[] = [];
function resolveRootPath(value: string | undefined, fallback: string) {
if (!value?.trim()) return fallback;
return path.isAbsolute(value) ? value : path.join(ROOT_DIR, value);
}
function relativePath(file: string | undefined) {
if (!file) return undefined;
return path.relative(ROOT_DIR, file);
}
function sanitizeRoute(route: string) {
return route.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root";
}
function sanitizeDetail(value: unknown) {
return String(value ?? "")
.replace(EMAIL_RE, "[email]")
.replace(UUID_RE, "[uuid]")
.replace(LONG_NUMBER_RE, "[long-number]")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/authorization:\s*bearer\s+[^\s]+/gi, "authorization: bearer ***")
.replace(/[A-Za-z0-9_+/:=-]{32,}/g, "***")
.slice(0, 300);
}
function outputPath(fileName: string) {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
return path.join(SCREENSHOT_DIR, fileName);
}
function addFinding(finding: Finding) {
findings.push({
...finding,
detail: sanitizeDetail(finding.detail),
screenshot: relativePath(finding.screenshot),
});
}
function getCredential(role: RoleKey) {
const env = ROLE_ENVS[role];
const identifier = process.env[env.identifier]?.trim();
const password = process.env[env.password]?.trim();
if (!identifier || !password) return null;
return { identifier, password };
}
async function loginRole(page: Page, role: RoleKey): Promise<RoleResult> {
const credential = getCredential(role);
if (!credential) {
return { role, login: "SKIP", reason: "missing_credentials" };
}
try {
await page.goto("/login");
await page.getByTestId("login-identifier").fill(credential.identifier);
await page.getByTestId("login-password").fill(credential.password);
await page.getByTestId("login-submit").click();
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
await page.waitForLoadState("networkidle").catch(() => undefined);
return { role, login: "PASS" };
} catch (error) {
const alertText = await page.locator(".text-red-600, .text-red-700, [role='alert']").first().textContent().catch(() => "");
return {
role,
login: "FAIL",
reason: sanitizeDetail(alertText || (error instanceof Error ? error.message : String(error))),
};
}
}
async function readMetrics(page: Page) {
return page.evaluate((englishTerms) => {
const text = document.body?.innerText ?? "";
const root = document.documentElement;
const width = window.innerWidth;
const height = window.innerHeight;
const scrollWidth = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
const headings = Array.from(document.querySelectorAll("h1,h2"))
.map((element) => (element.textContent ?? "").trim().replace(/\s+/g, " "))
.filter(Boolean)
.slice(0, 6);
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 inputsWithDatePlaceholders = Array.from(document.querySelectorAll("input"))
.filter((element) => {
const textValue = `${element.getAttribute("placeholder") ?? ""} ${element.getAttribute("value") ?? ""}`;
return element.getAttribute("type") === "date" || /mm\/dd\/yyyy|yyyy-mm-dd/i.test(textValue);
}).length;
const clippedControls = 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, 40),
}));
const englishFound = englishTerms.filter((term) => text.includes(term));
return {
title: document.title,
width,
height,
scrollWidth,
textLength: text.length,
headings,
visibleTables,
uuidCount: (text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi) ?? []).length,
longNumberCount: (text.match(/\b20[0-9]{12,}\b/g) ?? []).length,
rawStatusTokenCount: (text.match(/\b(active|draft|archived|available|pending|approved|rejected|executed|force_on|force_off)\b/gi) ?? []).length,
rawJsonBlockCount: (text.match(/\{\s*"[^"]+"\s*:/g) ?? []).length,
englishFound,
datePlaceholderCount: inputsWithDatePlaceholders,
clippedControls,
forbiddenLike: text.includes("无权访问") || text.includes("403") || text.includes("权限不足"),
appErrorLike: text.includes("页面加载失败") || text.includes("加载失败") || text.includes("出错了"),
navPortalCopy: text.includes("Portal"),
};
}, ENGLISH_PRIMARY_TERMS);
}
async function auditRoute(page: Page, plan: RoutePlan) {
await page.goto(plan.route);
await page.waitForLoadState("networkidle").catch(() => undefined);
await page.waitForTimeout(300);
const screenshot = outputPath(`${plan.role}-${sanitizeRoute(plan.route)}.png`);
await page.screenshot({ path: screenshot, fullPage: true });
const metrics = await readMetrics(page);
const finalPath = new URL(page.url()).pathname;
const forbidden = Boolean(metrics.forbiddenLike) || finalPath === "/403";
let status: RouteResult["status"] = "PASS";
if (forbidden && plan.expectation === "forbidden_ok") {
status = "FORBIDDEN_EXPECTED";
} else if (forbidden && plan.expectation === "accessible") {
status = "FINDING";
addFinding({
severity: "P1",
role: plan.role,
route: plan.route,
label: plan.label,
category: "权限/入口",
detail: "预期可访问的 PC 路由进入 403 或权限不足页面。",
screenshot,
});
} else if (forbidden) {
status = "SKIP";
}
if (!forbidden) {
if (metrics.appErrorLike) {
status = "FINDING";
addFinding({
severity: "P1",
role: plan.role,
route: plan.route,
label: plan.label,
category: "页面可用性",
detail: "页面正文出现加载失败或错误提示。",
screenshot,
});
}
if (metrics.scrollWidth > metrics.width + 2) {
status = "FINDING";
addFinding({
severity: "P1",
role: plan.role,
route: plan.route,
label: plan.label,
category: "响应式",
detail: `PC 视口横向溢出:viewport=${metrics.width}, scrollWidth=${metrics.scrollWidth}`,
screenshot,
});
}
if (plan.layer !== "C" && metrics.uuidCount > 0) {
status = "FINDING";
addFinding({
severity: "P1",
role: plan.role,
route: plan.route,
label: plan.label,
category: "业务展示",
detail: `A/B 类页面出现内部 UUID:count=${metrics.uuidCount}`,
screenshot,
});
}
if (plan.layer !== "C" && metrics.rawStatusTokenCount > 0) {
addFinding({
severity: "P2",
role: plan.role,
route: plan.route,
label: plan.label,
category: "文案/码值",
detail: `A/B 类页面出现英文状态或开关码值:count=${metrics.rawStatusTokenCount}`,
screenshot,
});
}
if (plan.layer !== "C" && metrics.longNumberCount > 0) {
addFinding({
severity: "P2",
role: plan.role,
route: plan.route,
label: plan.label,
category: "业务展示",
detail: `A/B 类页面出现长数字内部流水或时间戳:count=${metrics.longNumberCount}`,
screenshot,
});
}
if (plan.layer !== "C" && metrics.rawJsonBlockCount > 0) {
status = "FINDING";
addFinding({
severity: "P1",
role: plan.role,
route: plan.route,
label: plan.label,
category: "业务展示",
detail: `A/B 类页面直接展示 JSON/原始扩展字段:count=${metrics.rawJsonBlockCount}`,
screenshot,
});
}
if (plan.layer !== "C" && metrics.englishFound.length > 0) {
addFinding({
severity: "P2",
role: plan.role,
route: plan.route,
label: plan.label,
category: "本地化",
detail: `A/B 类页面出现英文主展示词:${metrics.englishFound.join(",")}`,
screenshot,
});
}
if (metrics.datePlaceholderCount > 0) {
addFinding({
severity: "P2",
role: plan.role,
route: plan.route,
label: plan.label,
category: "表单文案",
detail: `日期控件存在浏览器英文占位或 ISO 占位:count=${metrics.datePlaceholderCount}`,
screenshot,
});
}
if (metrics.clippedControls.length > 0) {
addFinding({
severity: "P2",
role: plan.role,
route: plan.route,
label: plan.label,
category: "文字裁切",
detail: JSON.stringify(metrics.clippedControls),
screenshot,
});
}
}
routeResults.push({
role: plan.role,
route: plan.route,
label: plan.label,
layer: plan.layer,
expectation: plan.expectation,
status,
finalPath,
screenshot: relativePath(screenshot),
metrics: {
title: sanitizeDetail(metrics.title),
textLength: metrics.textLength,
headingCount: metrics.headings.length,
tableCount: metrics.visibleTables,
uuidCount: metrics.uuidCount,
rawStatusTokenCount: metrics.rawStatusTokenCount,
longNumberCount: metrics.longNumberCount,
rawJsonBlockCount: metrics.rawJsonBlockCount,
englishFound: metrics.englishFound,
datePlaceholderCount: metrics.datePlaceholderCount,
clippedCount: metrics.clippedControls.length,
forbiddenLike: metrics.forbiddenLike,
appErrorLike: metrics.appErrorLike,
navPortalCopy: metrics.navPortalCopy,
},
});
}
function writeReport() {
mkdirSync(REPORT_DIR, { recursive: true });
const blockingFindings = findings.filter((finding) => finding.severity === "P1");
const skippedRoles = roleResults.filter((item) => item.login === "SKIP");
const failedRoles = roleResults.filter((item) => item.login === "FAIL");
const status = failedRoles.length
? "SKIP/BLOCKED_BY_EXTERNAL_CONFIG"
: blockingFindings.length
? "PASS_WITH_FINDINGS"
: skippedRoles.length || findings.length
? "PASS_WITH_FINDINGS"
: "PASS_PC_READONLY_VISUAL_READY";
const summary = {
issueId: "OAFRONT3-1",
mode: "pc-readonly-visual-evidence",
status,
conclusion: status,
baseUrl: BASE_URL,
checkedAt: new Date().toISOString(),
readOnly: true,
viewport: "1440x960",
roleCount: roleResults.length,
routeCount: routeResults.length,
screenshotDir: relativePath(SCREENSHOT_DIR),
reportDir: relativePath(REPORT_DIR),
roles: roleResults,
routes: routeResults,
findingCount: findings.length,
findings,
boundaries: {
noBusinessWrites: true,
noFileUploads: true,
noFormSubmits: true,
noExports: true,
noWeComWrites: true,
noRealPaymentExecution: true,
},
privacy: "no password/token/secret/full email/full phone/full local sensitive path/raw invoice/contract body/bank receipt/raw payload in JSON report",
};
writeFileSync(path.join(REPORT_DIR, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
}
test.describe.serial("OAFRONT3 PC no-write visual evidence", () => {
test.skip(process.env.E2E_MODE !== "live", "OAFRONT3 PC 只读视觉证据只在公网 live 模式执行");
test("角色化访问 PC 关键页面并生成只读视觉证据", async ({ browser }) => {
mkdirSync(REPORT_DIR, { recursive: true });
mkdirSync(SCREENSHOT_DIR, { recursive: true });
const roles = Array.from(new Set(ROUTE_PLANS.map((plan) => plan.role)));
for (const role of roles) {
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, serviceWorkers: "block", locale: "zh-CN" });
const page = await context.newPage();
try {
const loginResult = await loginRole(page, role);
roleResults.push(loginResult);
if (loginResult.login !== "PASS") {
continue;
}
for (const plan of ROUTE_PLANS.filter((item) => item.role === role)) {
await auditRoute(page, plan);
}
} finally {
await context.close();
}
}
writeReport();
});
});