486 行
19 KiB
TypeScript
486 行
19 KiB
TypeScript
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();
|
||
});
|
||
});
|