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; }; 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 = { 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: "财务应付账款", 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: "财务负责人应付账款", 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 { 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)); const forbiddenPage = window.location.pathname === "/403" || headings.some((heading) => /^(403|无权访问|权限不足)$/.test(heading)) || Array.from(document.querySelectorAll("[role='alert'],[data-testid='forbidden-page']")) .some((element) => /^(403|无权访问|权限不足)/.test((element.textContent ?? "").trim())); 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: forbiddenPage, appErrorLike: text.includes("页面加载失败") || text.includes("加载失败") || text.includes("出错了"), }; }, 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, }, }); } 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(); }); });