diff --git a/client/tests/e2e/oafront3-pc-readonly-visual.spec.ts b/client/tests/e2e/oafront3-pc-readonly-visual.spec.ts new file mode 100644 index 00000000..02bdf700 --- /dev/null +++ b/client/tests/e2e/oafront3-pc-readonly-visual.spec.ts @@ -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; +}; + +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: "财务 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 { + 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(); + }); +}); diff --git a/issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv b/issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv index a7eb051c..855ad2f3 100644 --- a/issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv +++ b/issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv @@ -1,15 +1,15 @@ "ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" "OAFRONT3-0","建立 PC 前端设计证据补强合同","在 OAFRONT2 基础上,专门补足 PC 版设计证据:现有 PC 截图、页面结构指标、视觉 token、文案/码值、权限角色覆盖和关键业务页改进建议;本合同只做证据与建议,不直接改 UI 代码、不部署、不写公网业务数据。","本 CSV 校验通过;每条 PC 建议有截图或代码指标依据、影响业务流程、后续自动/人工验证方式;明确旧截图可能过期,后续实施前需刷新无写入 PC 视觉矩阵。","受影响流程:PC 设计事实源 -> PC 截图/代码证据 -> 问题分类 -> 用户采纳决策;验证步骤:validate_issues_csv.py 本 CSV;check-process-contract general;git diff --check。","python3|node|git|manual","DONE","DONE","DONE","issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv","[]","已通过 validate_issues_csv.py、check-process-contract general、check-ui-design-rules 和 git diff --check。MAGI 审视:复核 OAFRONT2、docs/产品设计规则.md、r93/qg6/r106 等 PC 截图;执行:统计 75 个页面、15 个 ListWorkspace 页面、42 个 PageHeader 页面、11 个超过 1000 行页面、index.css 14 处 gradient/5 处 radial-gradient;提升:形成本 PC 专项建议台账。" -"OAFRONT3-1","补一套无公网写入的 PC 视觉证据矩阵","现有 PC 截图有价值但不够成熟:部分来自旧批次,部分用普通员工访问财务/AP/付款得到 403,部分截图显示旧路由标题而当前 schema 已修正;继续判断 PC 设计前需要一套 no-write、角色覆盖完整的当前截图证据。","新增或复用 QA-only PC 视觉矩阵,覆盖 employee、manager、finance、finance_mgr、admin/ceo 角色;只读访问 Portal、报销、审批、采购、合同、资产、票据、付款/AP、项目、配置页;报告标记角色、路由、权限结果,不创建业务数据。","受影响流程:PC 登录 -> 关键页面打开 -> 截图/DOM 指标 -> 设计判断;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com 运行 no-write PC visual evidence;确认 AP/付款不再因普通员工角色误判为设计问题;报告脱敏。","playwright|node|manual","TODO","TODO","TODO","scripts/qa/run-visual-matrix.sh;client/tests/e2e/project-wide-visual.spec.ts;artifacts/qa-flow","[""OAFRONT3-0""]","优先级 P0/P1。不得复用会创建报销草稿的 project-wide prepareRoutes 公网路径;若必须造测试数据,需另问并打 TEST/SMOKE 标记。" -"OAFRONT3-2","PC 导航和账号展示本地化不足","PC 截图和代码显示主导航仍有 Portal 英文标签,顶部账号示例仍出现 Employee/Manager/Admin 等英文 smoke 名;这不影响功能,但会削弱国内小团队正式启用时的专业感。","主导航用“工作台”替代 Portal;smoke/演示账号主展示使用中文姓名或中文职能名;真实员工按真实姓名展示;管理员排障页可保留角色码但不作为首页主称呼。","受影响流程:PC 登录 -> 左侧导航 -> 顶部账号 -> 培训演示;验证步骤:PC 截图不再以 Portal/Employee/Manager 作为普通主展示;不写真实员工隐私到仓库。","playwright|manual","TODO","TODO","TODO","client/src/components/navigation/NavLinks.tsx;client/src/features/navigation/schema.ts;seed|server/scripts","[""OAFRONT3-0""]","优先级 P1。证据:NavLinks.tsx 中 /portal label 为 Portal;PC Portal/审批截图顶部出现 Employee/Manager。" +"OAFRONT3-1","补一套无公网写入的 PC 视觉证据矩阵","现有 PC 截图有价值但不够成熟:部分来自旧批次,部分用普通员工访问财务/AP/付款得到 403,部分截图显示旧路由标题而当前 schema 已修正;继续判断 PC 设计前需要一套 no-write、角色覆盖完整的当前截图证据。","新增或复用 QA-only PC 视觉矩阵,覆盖 employee、manager、finance、finance_mgr、admin/ceo 角色;只读访问 Portal、报销、审批、采购、合同、资产、票据、付款/AP、项目、配置页;报告标记角色、路由、权限结果,不创建业务数据。","受影响流程:PC 登录 -> 关键页面打开 -> 截图/DOM 指标 -> 设计判断;验证步骤:bash scripts/qa/run-oafront3-pc-readonly-visual.sh;确认 AP/付款不再因普通员工角色误判为设计问题;报告脱敏。","playwright|node|manual","DONE","DONE","DONE","scripts/qa/run-oafront3-pc-readonly-visual.sh;client/tests/e2e/oafront3-pc-readonly-visual.spec.ts;artifacts/qa-flow/oafront3-pc-readonly-visual-*;output/playwright/oafront3-pc-readonly","[""OAFRONT3-0""]","已完成公网 no-write PC 矩阵:7 个角色登录均 PASS,32 张 PC 截图,summary=artifacts/qa-flow/oafront3-pc-readonly-visual-20260525-003341/summary.json,结论 PASS_WITH_FINDINGS,隐私扫描通过;发现 2 个 P1 为付款批次 A/B 页面暴露内部 UUID,P2 包括 26 个 Portal/Employee 等本地化问题、9 个日期控件英文占位/ISO 风险、4 个英文状态/开关码值、3 个长数字内部流水和 1 个合同输入裁切。" +"OAFRONT3-2","PC 导航和账号展示本地化不足","PC 截图和代码显示主导航仍有 Portal 英文标签,顶部账号示例仍出现 Employee/Manager/Admin 等英文 smoke 名;这不影响功能,但会削弱国内小团队正式启用时的专业感。","主导航用“工作台”替代 Portal;smoke/演示账号主展示使用中文姓名或中文职能名;真实员工按真实姓名展示;管理员排障页可保留角色码但不作为首页主称呼。","受影响流程:PC 登录 -> 左侧导航 -> 顶部账号 -> 培训演示;验证步骤:PC 截图不再以 Portal/Employee/Manager 作为普通主展示;不写真实员工隐私到仓库。","playwright|manual","TODO","TODO","TODO","client/src/components/navigation/NavLinks.tsx;client/src/features/navigation/schema.ts;seed|server/scripts","[""OAFRONT3-0""]","优先级 P1。证据:NavLinks.tsx 中 /portal label 为 Portal;OAFRONT3 公网 PC 矩阵显示 26 个 A/B 类页面命中 Portal 英文主展示词,AP 页面还命中 Employee。" "OAFRONT3-3","PC Portal 首屏仍偏展示化,工作台密度不足","Portal 页面截图显示 hero 面积和装饰圆纹明显,首屏大块留白较多;对 10 人小科技公司,PC 首页应更像日常处理台,优先呈现待办、草稿/退回、财务待处理、合同/资产提醒、最近动作和异常。","采纳后 Portal 首屏减少装饰 hero 和空白区,强化今日待处理、我的申请进度、财务/合同/资产提醒;不变成重型 ERP;保留 Hyinda 品牌蓝和清晰边界。","受影响流程:PC 登录 -> 今日工作台 -> 进入审批/报销/付款/合同/资产;验证步骤:PC 1440 当前截图与改后截图比对;真实 7 天反馈确认首屏能回答“我今天要处理什么”。","playwright|manual","TODO","TODO","TODO","client/src/pages/PortalHomePage.tsx;client/src/index.css;client/src/components/patterns/WorkbenchHero.tsx","[""OAFRONT3-1""]","优先级 P1/P2。建议先刷新证据和收集真实反馈,再小步收敛 hero,不做一次性重排。" -"OAFRONT3-4","PC 票据中心仍暴露内部编号和码值","历史 PC 票据中心截图显示票据流水号位置直接展示 UUID,详情区有状态码 available,表格中发票号码过长且占据主列;这和设计规则“业务对象用业务编号或摘要,不把 UUID 当主编号展示”冲突,也增加隐私报告误泄露风险。","票据列表主标识改为票据编号/发票号码脱敏尾号/销售方+金额摘要;UUID 仅在管理员审计或调试区折叠显示;状态使用“可添加/识别成功/已归档”等中文业务文案;导出/详情报告不泄露完整票面敏感值。","受影响流程:上传 PDF 发票 -> OCR -> 查看票据池 -> 绑定报销/复核;验证步骤:PC 票据池截图;check-ui-design-rules 增加 UUID 主展示/状态码检查;隐私扫描确认报告不含完整票据敏感值。","playwright|node|manual","TODO","TODO","TODO","client/src/pages/InvoicePoolPage.tsx;client/src/pages/invoice-pool/InvoiceListCard.tsx;client/src/pages/invoice-pool/InvoiceDetailPanel.tsx;scripts/qa/check-ui-design-rules.mjs","[""OAFRONT3-1""]","优先级 P1。证据来自 r93 desktop1440-invoice-pool;后续记录问题时不要复述完整发票号或真实票据内容。" -"OAFRONT3-5","合同台账录入表单过长且含英文状态/日期占位","合同台账截图显示左侧新建表单很长,右侧列表为空;编辑状态使用 active 等英文码值,日期控件显示 mm/dd/yyyy,占位和状态都偏技术化。正式启用时合同台账是 P0/P1 业务域,需要更清楚的录入路径。","合同台账应分为新建入口、基础信息、归档/责任、列表/筛选/详情;状态展示中文;日期提示符合中文语境;敏感合同正文仍不上传 OA,只记录摘要、归档位置和责任人。","受影响流程:管理员录入合同 -> 关联项目 -> 记录归档位置 -> 查询/到期提醒;验证步骤:PC 合同台账截图;创建 1 条 TEST/SMOKE 合同台账或人工录入验证;权限负例普通员工不可见敏感字段。","playwright|manual","TODO","TODO","TODO","client/src/pages/ContractsRegistryPage.tsx","[""OAFRONT3-1""]","优先级 P1。证据:ContractsRegistryPage EDITOR_STATUS_OPTIONS 使用 active/draft/archived 原值;历史截图显示 mm/dd/yyyy。" -"OAFRONT3-6","资产台账主动作优先级不够清晰","资产台账截图中分类管理表单占据左侧首屏,资产列表和详情在右侧;对 ASSET1D 试录场景,管理员最需要先完成 FA/EA-SYS/EA-REPO/EA-BKP 建卡,但主动作不如分类维护明显。","资产页首屏先表达资产建卡、筛选、列表、详情;分类维护降为配置区或折叠区;不同资产类型显示差异字段;敏感资料只存摘要、hash、责任人和归档位置。","受影响流程:管理员新建资产 -> 筛选资产 -> 查看详情 -> 领用/归还/盘点;验证步骤:PC 资产页截图;ASSET1D UI 试录 1 FA + 1 EA-SYS + 1 EA-REPO + 1 EA-BKP;普通员工权限负例。","playwright|manual","TODO","TODO","TODO","client/src/pages/AssetRegistryPage.tsx","[""OAFRONT3-1""]","优先级 P1。当前 ASSET1D 正在推进,资产页真实试录反馈应优先于主观视觉调整。" -"OAFRONT3-7","采购和项目列表空态占比偏大,下一步动作弱","PC 采购截图显示筛选、列表和详情区域均为空,占据较多空间;项目页类似,项目列表和新建项目提示分区较清楚但空态仍偏大。真实启用前空数据状态应强提示下一步,而不是让页面像未配置。","采购/项目空态应根据权限给出明确下一步:新建采购、补资料、关联项目、联系管理员;列表和详情空态不重复解释;有数据时保持高密度筛选和表格。","受影响流程:员工/管理员打开采购 -> 新建或筛选 -> 关联项目;管理员打开项目 -> 新建项目 -> 关联合同/采购/报销;验证步骤:PC 采购/项目截图;有数据和空数据两种状态截图;无 route-like 标题。","playwright|manual","TODO","TODO","TODO","client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ProjectsPage.tsx;client/src/pages/ProjectDetailPage.tsx","[""OAFRONT3-1""]","优先级 P2。当前不是 launch blocker,但会影响新用户首次理解。" -"OAFRONT3-8","财务/AP/付款 PC 证据角色覆盖不足","现有 r93 员工视角访问 AP/付款得到 403,不能证明财务页面设计成熟或不成熟;而 OAOPS1/OALAUNCH1 将付款批次、回单、AP 导出列为关键启用流程,必须用 finance/finance_mgr 角色补证据。","PC 财务证据覆盖付款批次、AP 运营、票据池、报销财务复核、导出入口;报告区分权限通过、无数据空态、真实设计问题;不执行真实付款、不上传银行流水原件。","受影响流程:财务登录 -> AP/付款批次 -> 上传回单 -> 标记已付款 -> 导出;验证步骤:finance/finance_mgr no-write 截图;OAOPS1 每日巡检;权限负例普通员工 403 仍保留但不当作设计缺陷。","playwright|manual","TODO","TODO","TODO","client/src/pages/ApOperationsPage.tsx;client/src/pages/PaymentBatchesPage.tsx;client/src/pages/ReimbursementsPage.tsx;client/src/pages/InvoicePoolPage.tsx","[""OAFRONT3-1""]","优先级 P0/P1,因为财务闭环是最终启用前关键门禁。" +"OAFRONT3-4","PC 票据中心仍暴露内部编号和码值","历史 PC 票据中心截图显示票据流水号位置直接展示 UUID,详情区有状态码 available,表格中发票号码过长且占据主列;这和设计规则“业务对象用业务编号或摘要,不把 UUID 当主编号展示”冲突,也增加隐私报告误泄露风险。","票据列表主标识改为票据编号/发票号码脱敏尾号/销售方+金额摘要;UUID 仅在管理员审计或调试区折叠显示;状态使用“可添加/识别成功/已归档”等中文业务文案;导出/详情报告不泄露完整票面敏感值。","受影响流程:上传 PDF 发票 -> OCR -> 查看票据池 -> 绑定报销/复核;验证步骤:PC 票据池截图;check-ui-design-rules 增加 UUID 主展示/状态码检查;隐私扫描确认报告不含完整票据敏感值。","playwright|node|manual","TODO","TODO","TODO","client/src/pages/InvoicePoolPage.tsx;client/src/pages/invoice-pool/InvoiceListCard.tsx;client/src/pages/invoice-pool/InvoiceDetailPanel.tsx;scripts/qa/check-ui-design-rules.mjs","[""OAFRONT3-1""]","优先级 P1。证据来自 r93 desktop1440-invoice-pool;OAFRONT3 公网 PC 矩阵补充显示员工票据中心仍命中长数字内部流水/时间戳;后续记录问题时不要复述完整发票号或真实票据内容。" +"OAFRONT3-5","合同台账录入表单过长且含英文状态/日期占位","合同台账截图显示左侧新建表单很长,右侧列表为空;编辑状态使用 active 等英文码值,日期控件显示 mm/dd/yyyy,占位和状态都偏技术化。正式启用时合同台账是 P0/P1 业务域,需要更清楚的录入路径。","合同台账应分为新建入口、基础信息、归档/责任、列表/筛选/详情;状态展示中文;日期提示符合中文语境;敏感合同正文仍不上传 OA,只记录摘要、归档位置和责任人。","受影响流程:管理员录入合同 -> 关联项目 -> 记录归档位置 -> 查询/到期提醒;验证步骤:PC 合同台账截图;创建 1 条 TEST/SMOKE 合同台账或人工录入验证;权限负例普通员工不可见敏感字段。","playwright|manual","TODO","TODO","TODO","client/src/pages/ContractsRegistryPage.tsx","[""OAFRONT3-1""]","优先级 P1。证据:ContractsRegistryPage EDITOR_STATUS_OPTIONS 使用 active/draft/archived 原值;历史截图显示 mm/dd/yyyy;OAFRONT3 公网 PC 矩阵新增确认合同台账有 4 个英文状态/开关码值、5 个日期英文占位/ISO 风险和 3 个疑似输入裁切。" +"OAFRONT3-6","资产台账主动作优先级不够清晰","资产台账截图中分类管理表单占据左侧首屏,资产列表和详情在右侧;对 ASSET1D 试录场景,管理员最需要先完成 FA/EA-SYS/EA-REPO/EA-BKP 建卡,但主动作不如分类维护明显。","资产页首屏先表达资产建卡、筛选、列表、详情;分类维护降为配置区或折叠区;不同资产类型显示差异字段;敏感资料只存摘要、hash、责任人和归档位置。","受影响流程:管理员新建资产 -> 筛选资产 -> 查看详情 -> 领用/归还/盘点;验证步骤:PC 资产页截图;ASSET1D UI 试录 1 FA + 1 EA-SYS + 1 EA-REPO + 1 EA-BKP;普通员工权限负例。","playwright|manual","TODO","TODO","TODO","client/src/pages/AssetRegistryPage.tsx","[""OAFRONT3-1""]","优先级 P1。当前 ASSET1D 正在推进,资产页真实试录反馈应优先于主观视觉调整;OAFRONT3 公网 PC 矩阵补充显示管理员资产页命中 1 个英文状态/码值、1 个长数字内部流水和 4 个日期英文占位/ISO 风险。" +"OAFRONT3-7","采购和项目列表空态占比偏大,下一步动作弱","PC 采购截图显示筛选、列表和详情区域均为空,占据较多空间;项目页类似,项目列表和新建项目提示分区较清楚但空态仍偏大。真实启用前空数据状态应强提示下一步,而不是让页面像未配置。","采购/项目空态应根据权限给出明确下一步:新建采购、补资料、关联项目、联系管理员;列表和详情空态不重复解释;有数据时保持高密度筛选和表格。","受影响流程:员工/管理员打开采购 -> 新建或筛选 -> 关联项目;管理员打开项目 -> 新建项目 -> 关联合同/采购/报销;验证步骤:PC 采购/项目截图;有数据和空数据两种状态截图;无 route-like 标题。","playwright|manual","TODO","TODO","TODO","client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ProjectsPage.tsx;client/src/pages/ProjectDetailPage.tsx","[""OAFRONT3-1""]","优先级 P2。当前不是 launch blocker,但会影响新用户首次理解;OAFRONT3 公网 PC 矩阵补充显示采购/项目页面仍有长数字内部流水和日期英文占位/ISO 风险。" +"OAFRONT3-8","财务/AP/付款 PC 证据角色覆盖不足","现有 r93 员工视角访问 AP/付款得到 403,不能证明财务页面设计成熟或不成熟;而 OAOPS1/OALAUNCH1 将付款批次、回单、AP 导出列为关键启用流程,必须用 finance/finance_mgr 角色补证据。","PC 财务证据覆盖付款批次、AP 运营、票据池、报销财务复核、导出入口;报告区分权限通过、无数据空态、真实设计问题;不执行真实付款、不上传银行流水原件。","受影响流程:财务登录 -> AP/付款批次 -> 上传回单 -> 标记已付款 -> 导出;验证步骤:finance/finance_mgr no-write 截图;OAOPS1 每日巡检;权限负例普通员工 403 仍保留但不当作设计缺陷。","playwright|manual","TODO","TODO","TODO","client/src/pages/ApOperationsPage.tsx;client/src/pages/PaymentBatchesPage.tsx;client/src/pages/ReimbursementsPage.tsx;client/src/pages/InvoicePoolPage.tsx","[""OAFRONT3-1""]","优先级 P0/P1,因为财务闭环是最终启用前关键门禁。OAFRONT3 公网 PC 矩阵已用 finance/finance_mgr 补证据:付款批次页面 2 个角色均出现 P1 内部 UUID 主展示,且各有 10 个英文状态码值和 2 个日期英文占位/ISO 风险;AP 页面命中 Portal/Employee 英文主展示词和日期占位;普通员工付款 403 为预期权限负例。" "OAFRONT3-9","C 类配置页过密但可作为后台模式保留","功能开关和工作区设置截图显示大量开关、flag key、force_on/force_off、main/admin/hidden 等码值;这对管理员排障有价值,但不应扩散到普通业务页。C 类页面需要明确“高密度后台样板”,而非员工化。","C 类页面保留必要码值和审计字段,但应分组、折叠高风险配置、中文化状态摘要;危险操作要确认;普通员工不可见;不把 C 类样式迁移到 A/B 业务页。","受影响流程:管理员配置功能开关/工作区/流程 -> 保存/回滚 -> 审计;验证步骤:admin PC 截图;权限负例;危险操作确认;普通业务页无 raw flag key 主展示。","playwright|manual","TODO","TODO","TODO","client/src/pages/FeatureFlagsPage.tsx;client/src/pages/WorkspaceSetupPage.tsx;client/src/pages/SystemSettingsPage.tsx;client/src/pages/WorkflowDefinitionsPage.tsx","[""OAFRONT3-1""]","优先级 P2。不要在 launch 前为了美观重构后台配置页。" "OAFRONT3-10","PC 全局视觉 token 偏装饰化的量化证据","静态扫描显示 index.css 有 14 处 gradient、5 处 radial-gradient、19 处 shadow 相关声明,workbench-hero 和 section-panel 还有装饰圆纹/顶边渐变;这支持 OAFRONT2 对 PC 视觉偏柔和展示化的判断。","采纳后做 token 级小实验:A/B 类业务页减少装饰性渐变和大圆角,保留品牌蓝、浅背景、清晰边界;C 类后台页面保持可读密度;移动矩阵不回归。","受影响流程:PC 工作台和业务台账扫读;验证步骤:CSS 静态扫描前后对比;PC 代表页截图;移动视觉矩阵;不引入单一蓝色或黑色大块主题。","node|playwright|manual","TODO","TODO","TODO","client/src/index.css;client/src/design/tokens.ts;client/src/design/theme.css;client/src/components/patterns","[""OAFRONT3-0""]","优先级 P2。建议作为视觉 token 试验合同,不逐页手改。" "OAFRONT3-11","页面复杂度阻碍 PC 设计持续演进","静态统计显示 75 个页面中 11 个超过 1000 行、18 个超过 700 行;PC 相关高复杂页面包括 ReimbursementDetail、PurchaseRequests、ApOperations、EmployeeProfile、AssetRegistry、PortalHome、ProjectDetail、LeaveRequest、Reimbursements。复杂度会让设计一致性修复和回归成本上升。","按真实反馈逐页拆分,不改变 API、权限、状态机或路由;优先从业务高频且问题明确的页面开始,例如票据池详情、合同台账、资产台账、财务/AP、Portal。","受影响流程:页面维护 -> 设计修复 -> 回归;验证步骤:client typecheck/build;相关 Playwright;单页改动截图;文件复杂度下降但业务行为不变。","node|typescript|playwright","TODO","TODO","TODO","client/src/pages/ReimbursementDetailPage.tsx;client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ApOperationsPage.tsx;client/src/pages/AssetRegistryPage.tsx;client/src/pages/PortalHomePage.tsx;client/src/pages/ProjectDetailPage.tsx","[""OAFRONT3-0""]","优先级 P2。launch 前不建议大拆分,除非对应页面出现真实 blocker。" "OAFRONT3-12","PC 文案密度和说明位置需要按页面层级治理","统计显示 PortalHomePage 有 26 处 subtitle/description,ProjectDetail 11 处,Attendance/Profile/Leave 等也较多;PC 端可以比移动承载更多说明,但 A/B 高频页首屏仍应以动作和状态为主。","A 类和 B 类页面首屏压缩说明,只保留业务状态和下一步;C 类配置页保留必要说明;长解释移到帮助、折叠区、字段提示或管理员文档。","受影响流程:员工/审批人/财务打开 PC 高频页 -> 判断下一步;验证步骤:rg subtitle/description 清单;PC 截图首屏;真实反馈确认说明没有挤占主动作。","rg|manual|playwright","TODO","TODO","TODO","client/src/pages/PortalHomePage.tsx;client/src/pages/ProjectDetailPage.tsx;client/src/pages/AttendancePage.tsx;client/src/pages/ProfilePage.tsx;client/src/pages/LeaveRequestPage.tsx","[""OAFRONT3-0""]","优先级 P1/P2。建议随具体页面优化一起清理,不做全局一刀切。" -"OAFRONT3-13","用户采纳 PC 改进问答门禁","PC 证据补强后,需要用户决定哪些进入实施:先做 no-write 证据矩阵和财务角色截图,还是直接改 Portal/票据/合同/资产;避免审美重排干扰最终启用。","用户至少对四组给出 A/B/C:1) 先做 PC no-write 当前证据矩阵;2) 先修票据 UUID/状态码和合同/资产高频 P1;3) Portal/视觉 token 是否等 7 天反馈;4) 大页面拆分是否后置。未采纳项不得进入开发。","受影响流程:PC 证据 -> 用户决策 -> 新实施合同 -> 开发/暂缓/否决;验证步骤:人工回填用户选择并 validate CSV。","manual|python3","TODO","TODO","TODO","issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv","[""OAFRONT3-0""]","默认推荐:组1=A,组2=A 但分小合同,组3=B 等 7 天真实反馈,组4=B 后置。" +"OAFRONT3-13","用户采纳 PC 改进问答门禁","PC 证据补强后,需要用户决定哪些进入实施:先做 no-write 证据矩阵和财务角色截图,还是直接改 Portal/票据/合同/资产;避免审美重排干扰最终启用。","用户至少对四组给出 A/B/C:1) 先做 PC no-write 当前证据矩阵;2) 先修票据 UUID/状态码和合同/资产高频 P1;3) Portal/视觉 token 是否等 7 天反馈;4) 大页面拆分是否后置。未采纳项不得进入开发。","受影响流程:PC 证据 -> 用户决策 -> 新实施合同 -> 开发/暂缓/否决;验证步骤:人工回填用户选择并 validate CSV。","manual|python3","TODO","TODO","TODO","issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv","[""OAFRONT3-0""]","当前推荐更新:组1已完成;组2=A 但拆成小合同,优先付款批次 UUID/英文状态/日期、合同台账状态/日期/裁切、资产台账字段密度和日期;组3=B,Portal 先修命名本地化,hero/视觉 token 等 7 天真实反馈;组4=B,大页面拆分后置。" diff --git a/scripts/qa/run-oafront3-pc-readonly-visual.sh b/scripts/qa/run-oafront3-pc-readonly-visual.sh new file mode 100755 index 00000000..88288f6c --- /dev/null +++ b/scripts/qa/run-oafront3-pc-readonly-visual.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLIENT_DIR="$ROOT_DIR/client" +PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" +REPORT_DIR="${OAFRONT3_PC_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/oafront3-pc-readonly-visual-$(date +%Y%m%d-%H%M%S)}" +OUTPUT_DIR="${OAFRONT3_PC_OUTPUT_DIR:-$ROOT_DIR/output/playwright/oafront3-pc-readonly}" + +load_default_env() { + local env_file="${OAFRONT3_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}" + if [[ "${OAFRONT3_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then + return 0 + fi + + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a + PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}" + echo "[oafront3] loaded masked live smoke env file" +} + +write_fallback_summary() { + local exit_code="$?" + if [[ -f "$REPORT_DIR/summary.json" ]]; then + return "$exit_code" + fi + + mkdir -p "$REPORT_DIR" + QA_REPORT_DIR="$REPORT_DIR" \ + QA_ISSUE_ID="OAFRONT3-1" \ + QA_BASE_URL="$PUBLIC_BASE_URL" \ + QA_MODE="pc-readonly-visual-evidence" \ + QA_STATUS="FAIL" \ + QA_SUMMARY_NOTES="Playwright 未生成 summary.json;查看脚本日志和 playwright-report。" \ + node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true + return "$exit_code" +} + +run_privacy_smoke() { + local sensitive_pattern='(["'\''"]?(WECOM_[A-Z0-9_]*(SECRET|TOKEN)|[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|ENCODING_AES_KEY|ENCODINGAESKEY|CORP_SECRET|ACCESS_TOKEN)[A-Z0-9_]*)["'\''"]?[[:space:]]*[:=][[:space:]]*["'\''"]?[A-Za-z0-9._:/+=-]{8,}["'\''"]?)|Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._-]{8,}|access_token=[A-Za-z0-9._-]{8,}|1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}|-----BEGIN|BEGIN [A-Z ]*KEY|"((token|secret|password|EncodingAESKey|encodingAESKey|sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId))"[[:space:]]*:[[:space:]]*"[^"*]{8,}"|\b20[0-9]{12,}\b|"(raw|rawPayload|raw_payload|payload|xml|ciphertext|Encrypt|formValue|formValues|attachments|comments)"[[:space:]]*:' + local matches + matches="$(grep -RInE "$sensitive_pattern" "$REPORT_DIR" || true)" + if [[ -n "$matches" ]]; then + printf '%s\n' "$matches" >&2 + echo "[oafront3][FAIL] report privacy smoke matched sensitive pattern" >&2 + return 1 + fi + echo "[oafront3][PASS] report privacy smoke" +} + +trap write_fallback_summary EXIT + +load_default_env +mkdir -p "$REPORT_DIR" "$OUTPUT_DIR" + +echo ">>> [oafront3] PC readonly visual evidence base=$PUBLIC_BASE_URL" +( + cd "$CLIENT_DIR" + E2E_MODE=live \ + LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ + OAFRONT3_PC_REPORT_DIR="$REPORT_DIR" \ + OAFRONT3_PC_OUTPUT_DIR="$OUTPUT_DIR" \ + npx playwright test tests/e2e/oafront3-pc-readonly-visual.spec.ts --workers=1 --reporter=list +) + +run_privacy_smoke +echo "[oafront3] summary=$REPORT_DIR/summary.json"