diff --git a/client/src/pages/WorkflowTaskDetailPage.tsx b/client/src/pages/WorkflowTaskDetailPage.tsx index 79b68c1f..b20042c8 100644 --- a/client/src/pages/WorkflowTaskDetailPage.tsx +++ b/client/src/pages/WorkflowTaskDetailPage.tsx @@ -230,6 +230,7 @@ export function WorkflowTaskDetailPage() { const detail = detailQuery.data; const canTransfer = allowedActions.has("transfer"); const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / "); + const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus); return ( @@ -239,9 +240,8 @@ export function WorkflowTaskDetailPage() { meta={( <> - 任务ID:{detail.task.id} 对象类型:{entityTitle(detail.workflowInstance?.entityType)} - 实例:{detail.workflowInstance?.id ?? "-"} + 业务状态:{businessStatusLabel} )} actions={( @@ -276,7 +276,7 @@ export function WorkflowTaskDetailPage() {
状态:{workflowStatusLabel(detail.task.status)}
-
实例:{detail.workflowInstance?.id ?? "-"}
+
业务状态:{businessStatusLabel}
对象类型:{entityTitle(detail.workflowInstance?.entityType)}
{detail.reimbursement ? ( <> @@ -317,7 +317,7 @@ export function WorkflowTaskDetailPage() { ) : null} {detail.travelRequest ? ( <> -
差旅单号:{detail.travelRequest.requestNo ?? detail.travelRequest.id}
+
差旅单号:{detail.travelRequest.requestNo ?? "暂未生成"}
申请人:{detail.travelRequest.applicant?.name ?? "-"}
出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}
法体:{detail.travelRequest.legalEntity?.name ?? "-"}
diff --git a/client/tests/e2e/project-wide-visual.spec.ts b/client/tests/e2e/project-wide-visual.spec.ts index a1a1d03c..5414742d 100644 --- a/client/tests/e2e/project-wide-visual.spec.ts +++ b/client/tests/e2e/project-wide-visual.spec.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { expect, test, type Browser, type Page } from "@playwright/test"; @@ -30,14 +31,41 @@ type PreparedRoutes = { 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 ISSUE_ID = process.env.QA_ISSUE_ID?.trim() || "project-wide-visual"; +const BASE_URL = process.env.E2E_BASE_URL?.trim() || null; +const SCREENSHOT_DIR = resolveRootPath( + process.env.QA_VISUAL_OUTPUT_DIR, + path.join(ROOT_DIR, "output/playwright/project-wide-visual"), +); +const ARTIFACT_DIR = resolveRootPath( + process.env.QA_VISUAL_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 resolveRootPath(value: string | undefined, fallback: string) { + if (!value?.trim()) { + return fallback; + } + return path.isAbsolute(value) ? value : path.join(ROOT_DIR, value); +} + +function currentCommit() { + try { + return execSync("git rev-parse --short HEAD", { + cwd: ROOT_DIR, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unknown"; + } +} + function outputPath(dir: string, fileName: string) { mkdirSync(dir, { recursive: true }); return path.join(dir, fileName); @@ -357,7 +385,7 @@ async function auditPlans(browser: Browser, plans: Array<{ route: string; domain 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" }); + : await browser.newContext({ viewport: { width: 1440, height: 960 }, serviceWorkers: "block" }); try { const page = await context.newPage(); await loginForVisual(page, user); @@ -382,7 +410,15 @@ test.describe("项目级全页面视觉巡检", () => { const reportPath = outputPath(ARTIFACT_DIR, "report.json"); writeFileSync(reportPath, JSON.stringify({ + issueId: ISSUE_ID, + baseURL: BASE_URL, + commit: currentCommit(), checkedAt: new Date().toISOString(), + status: findings.some((finding) => finding.severity === "P1") ? "FAIL" : "PASS", + matrix: { + pc: "1440x960", + mobileWeCom: "390x844", + }, pcRoutes: pcPlans.map((item) => item.route), mobileRoutes: mobilePlans.map((item) => item.route), findings, diff --git a/docs/trialrun/manual-trialrun-report.md b/docs/trialrun/manual-trialrun-report.md index 242dc100..179c8124 100644 --- a/docs/trialrun/manual-trialrun-report.md +++ b/docs/trialrun/manual-trialrun-report.md @@ -14,8 +14,8 @@ | 项目 | 记录 | | --- | --- | | 公网地址 | `https://oa.hyinda.com` | -| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6/LP6 后续优化已完成;ME6 自动化、部署和公网视觉复检已完成,`ME6-3` 代表性企业微信真机抽查因设备不便暂缓;真机暂缓期间可先做 QG6 非业务 QA 治理,但 QG6 不替代真机 | -| 当前代码基线 | `96e5d2e`;ME6 移动二轮小修已部署到 `https://oa.hyinda.com`,并完成 `/ready`、public bundle、live smoke readiness 和公网移动视觉矩阵复检;真机抽查待用户确认 | +| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6/LP6 后续优化已完成;ME6 自动化、部署和公网视觉复检已完成,`ME6-3` 代表性企业微信真机抽查因设备不便暂缓;QG6 非业务 QA 治理已完成但不替代真机 | +| 当前代码基线 | QG6 QA 治理升级已部署到 `https://oa.hyinda.com`,并完成 `/ready`、public bundle、live smoke readiness、公网移动视觉矩阵复检;真机抽查待用户确认 | | 试运行窗口 | `2026-05-12 14:00-17:00 Asia/Shanghai`;若现场无法执行,顺延到 `2026-05-13 14:00-17:00 Asia/Shanghai` | | 回滚联系人 | `wangshunli` | | 问题登记负责人 | `wangshunli` | @@ -100,6 +100,8 @@ ME6 自动化与部署收口(2026-05-12):移动二轮体验小修已完成 ND9 非真机延续(2026-05-12):用户反馈当前不方便做代表性企业微信真机抽查,因此 `ME6-3` 继续保持 `TODO`,不得伪造完成或用自动化替代。后续可先执行 QG6 QA 治理、R7 草案、运维交接和培训材料等不依赖真机的工作;这些工作不得改变业务规则、审批链、PDF-only、企业微信通知通道或财务规则。R7 真实扩大试运行仍需用户确认最终人员、窗口,并补足 ME6 真机门禁或明确接受该风险。 +QG6 QA 治理收口(2026-05-12):非真机阶段 QA 治理升级已完成并部署。`check-ui-design-rules.mjs` 已增强 UUID 主展示、内部术语、英文上传、移动桌面表格和 PC 壳扫描,新增 `--self-test` 和白名单说明;PC 审批详情不再展示任务 UUID 或 workflow instance UUID。新增 `scripts/qa/run-visual-matrix.sh` 固定视觉矩阵,项目级视觉报告支持 issue-id、baseURL、commit 和状态字段;`run-business-flow-gates.sh` 与 `run-live-smoke-readiness.sh` 输出统一 `summary.json`。本地 `client typecheck/build`、`server prisma:validate`、`check-ui-design-rules --self-test/--strict` 通过;QG6 本地视觉矩阵 8 passed,报告 `artifacts/qa-flow/qg6-qa-governance-upgrade-visual-matrix-20260512-172418`,业务流 `reimbursement,leave,procurement` 全部通过,报告 `artifacts/qa-flow/business-flow-20260512-172944`。已部署到 `https://oa.hyinda.com`,公网 `/ready` 正常,public bundle `findings=0`,live smoke readiness PASS,报告 `artifacts/qa-flow/live-smoke-readiness-20260512-174055`;公网移动视觉矩阵 7 passed,报告 `artifacts/qa-flow/qg6-qa-governance-upgrade-visual-matrix-20260512-174055`。`ME6-3` 代表性企业微信真机抽查仍为 TODO,QG6 不替代真机验收;下一步只允许 R7 草案、OPS7/DOC7 材料等非真机准备,真实 R7 仍需用户确认人员、窗口和风险接受。 + ## 结论 R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。基于本地环境复测,经理团队日历与员工报销缺票提示/发票上传绑定提交链路均已通过,可恢复下一轮本地人工试运行。 diff --git a/issues/2026-05-12_01-47-00-r6-qa-governance-upgrade.csv b/issues/2026-05-12_01-47-00-r6-qa-governance-upgrade.csv index b39d271e..369568ac 100644 --- a/issues/2026-05-12_01-47-00-r6-qa-governance-upgrade.csv +++ b/issues/2026-05-12_01-47-00-r6-qa-governance-upgrade.csv @@ -1,6 +1,6 @@ ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes QG6-1,QA 治理升级合同建档,规划 R6 后自动化、视觉和设计规则扫描升级。,"1) 本 CSV 校验通过;2) 不改业务功能;3) 输出路径和敏感截图规则明确;4) 后续页面改动默认带视觉或业务回归。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-47-00-r6-qa-governance-upgrade.csv。",python3,DONE,DONE,DONE,issues/2026-05-12_01-47-00-r6-qa-governance-upgrade.csv,[],治理升级可与后续页面优化并行规划。 -QG6-2,设计规则静态扫描增强,扩展 `check-ui-design-rules.mjs` 扫描 UUID 主展示、内部术语、英文上传、桌面表格误入移动和 PC 壳误入 /mobile。,"1) 扫描规则覆盖已知问题;2) 误报有白名单说明;3) strict 模式用于门禁;4) 不扫描 secret 内容。","流程级验证:node scripts/qa/check-ui-design-rules.mjs --strict;构造或保留 fixture 验证规则命中。",node,TODO,TODO,TODO,scripts/qa/check-ui-design-rules.mjs|client/src,"[""QG6-1""]",白名单必须有理由。 -QG6-3,固定视觉矩阵脚本,建立 PC 1440x960、移动 320/360/375/390/393/414/430、企业微信 iOS/Android UA 和普通移动 UA 的巡检脚本。,"1) 矩阵可按 issue-id 输出截图;2) 支持本地和公网 baseURL;3) 核心断言包含无横向滚动、底部 Tab 命中和无 PC 壳。","流程级验证:运行视觉矩阵脚本,截图输出到 output/playwright//,检查摘要报告。",playwright|node,TODO,TODO,TODO,client/tests/e2e|scripts/qa,"[""QG6-2""]",真实企业微信仍需代表性真机确认。 -QG6-4,业务流报告规范化,统一 artifacts/qa-flow 的业务流、live smoke、OCR 和公网扫描报告命名与摘要。,"1) 每轮报告能追溯 issue-id、baseURL、commit;2) 不包含密码和 token;3) 失败摘要可直接登记 Issue。","流程级验证:运行 run-business-flow-gates 和 run-live-smoke-readiness -> 检查报告字段。",bash|node,TODO,TODO,TODO,scripts/qa|artifacts/qa-flow,"[""QG6-3""]",历史报告不强制迁移。 -QG6-5,QA 治理回归部署,完成治理脚本后运行静态扫描、视觉矩阵、业务流和公网复检。,"1) 静态扫描通过;2) 视觉矩阵通过;3) 业务流关键组通过;4) 部署后 live smoke 通过。","流程级验证:node scripts/qa/check-ui-design-rules.mjs --strict;Playwright 视觉矩阵;BUSINESS_FLOW_GROUPS=reimbursement,leave,procurement bash scripts/qa/run-business-flow-gates.sh local;deploy;live smoke readiness。",node|playwright|bash,TODO,TODO,TODO,scripts/qa|client/tests/e2e|deploy/trialrun,"[""QG6-4""]",治理脚本变更不应修改业务行为。 +QG6-2,设计规则静态扫描增强,扩展 `check-ui-design-rules.mjs` 扫描 UUID 主展示、内部术语、英文上传、桌面表格误入移动和 PC 壳误入 /mobile。,"1) 扫描规则覆盖已知问题;2) 误报有白名单说明;3) strict 模式用于门禁;4) 不扫描 secret 内容。","流程级验证:node scripts/qa/check-ui-design-rules.mjs --strict;构造或保留 fixture 验证规则命中。",node,DONE,DONE,DONE,scripts/qa/check-ui-design-rules.mjs|client/src,"[""QG6-1""]",已增加 --self-test 和 suppressed 白名单输出;strict 0 findings,ErpIntegrationPage 管理员队列 ID 仅白名单抑制。 +QG6-3,固定视觉矩阵脚本,建立 PC 1440x960、移动 320/360/375/390/393/414/430、企业微信 iOS/Android UA 和普通移动 UA 的巡检脚本。,"1) 矩阵可按 issue-id 输出截图;2) 支持本地和公网 baseURL;3) 核心断言包含无横向滚动、底部 Tab 命中和无 PC 壳。","流程级验证:运行视觉矩阵脚本,截图输出到 output/playwright//,检查摘要报告。",playwright|node,DONE,DONE,DONE,client/tests/e2e|scripts/qa,"[""QG6-2""]",本地 QG6 visual matrix 8 passed,报告 artifacts/qa-flow/qg6-qa-governance-upgrade-visual-matrix-20260512-172418;公网移动矩阵 7 passed,报告 artifacts/qa-flow/qg6-qa-governance-upgrade-visual-matrix-20260512-174055;真实企业微信仍需 ME6-3 真机确认。 +QG6-4,业务流报告规范化,统一 artifacts/qa-flow 的业务流、live smoke、OCR 和公网扫描报告命名与摘要。,"1) 每轮报告能追溯 issue-id、baseURL、commit;2) 不包含密码和 token;3) 失败摘要可直接登记 Issue。","流程级验证:运行 run-business-flow-gates 和 run-live-smoke-readiness -> 检查报告字段。",bash|node,DONE,DONE,DONE,scripts/qa|artifacts/qa-flow,"[""QG6-3""]",新增 scripts/qa/write-qa-summary.mjs;business-flow 和 live-readiness 均输出 summary.json,已验证 artifacts/qa-flow/business-flow-20260512-172944 与 live-smoke-readiness-20260512-174055。 +QG6-5,QA 治理回归部署,完成治理脚本后运行静态扫描、视觉矩阵、业务流和公网复检。,"1) 静态扫描通过;2) 视觉矩阵通过;3) 业务流关键组通过;4) 部署后 live smoke 通过。","流程级验证:node scripts/qa/check-ui-design-rules.mjs --strict;Playwright 视觉矩阵;BUSINESS_FLOW_GROUPS=reimbursement,leave,procurement bash scripts/qa/run-business-flow-gates.sh local;deploy;live smoke readiness。",node|playwright|bash,DONE,DONE,DONE,scripts/qa|client/tests/e2e|deploy/trialrun,"[""QG6-4""]",本地 typecheck/build、prisma validate、静态扫描、自检、视觉矩阵和业务流通过;已部署 https://oa.hyinda.com,/ready 正常,public bundle findings=0,live readiness PASS;缺少 live smoke 凭据和真实 OCR 样票时按规则 SKIP。 diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs index 3211c820..6d275ca2 100644 --- a/scripts/qa/check-ui-design-rules.mjs +++ b/scripts/qa/check-ui-design-rules.mjs @@ -6,6 +6,8 @@ import process from "node:process"; const rootDir = process.cwd(); const strict = process.argv.includes("--strict"); const jsonOutput = process.argv.includes("--json"); +const selfTest = process.argv.includes("--self-test"); +const showSuppressed = process.argv.includes("--show-suppressed"); const scanRoots = [ "client/src/components", @@ -25,6 +27,14 @@ const userFacingBusinessTermFiles = new Set([ ]); const forbiddenUserFacingBusinessTerms = ["绑定整单", "不绑定明细", "发票池", "OCR"]; const findings = []; +const suppressedFindings = []; +const ruleAllowlist = [ + { + rule: "data.no_uuid_primary_display", + filePattern: /^client\/src\/pages\/(?:AuditLogs|UserManagement|Departments|Delegations|ApprovalRules|WorkflowDefinitions|ErpIntegration|FeatureFlags|CutoverConsole|MasterData|Budgets|Payroll|Performance|People|EmployeeProfile|Organization|ReimbursementPrint)Page\.tsx$/, + reason: "管理员、审计或打印留痕页面允许展示内部记录 ID。", + }, +]; function toPosix(value) { return value.split(path.sep).join("/"); @@ -59,15 +69,29 @@ function lineNumber(source, index) { return source.slice(0, index).split("\n").length; } +function normalizeFindingFile(file) { + return toPosix(path.isAbsolute(file) ? path.relative(rootDir, file) : file); +} + +function findSuppression(finding) { + return ruleAllowlist.find((item) => item.rule === finding.rule && item.filePattern.test(finding.file)); +} + function addFinding({ rule, severity = "warning", file, line = 1, message, evidence }) { - findings.push({ + const finding = { rule, severity, - file: toPosix(path.relative(rootDir, file)), + file: normalizeFindingFile(file), line, message, evidence, - }); + }; + const suppression = findSuppression(finding); + if (suppression) { + suppressedFindings.push({ ...finding, suppressionReason: suppression.reason }); + return; + } + findings.push(finding); } function scanForbiddenCopy(file, source) { @@ -130,6 +154,34 @@ function scanNativeFileInputs(file, source) { } } +function scanVisibleEnglishUploadCopy(file, source) { + const patterns = [ + { + regex: />\s*(Choose File|No file chosen|Upload|Uploading|Browse)\s*
任务ID:{task.id}
; + } + `, + expectedRules: [ + "mobile.no_desktop_table", + "mobile.no_pc_shell", + "copy.no_english_upload_labels", + "data.no_uuid_primary_display", + ], + expectedSuppressedRules: [], + }, + { + file: "client/src/pages/AuditLogsPage.tsx", + source: ` + export function AuditLogs() { + return
记录ID:{item.id}
; + } + `, + expectedRules: [], + expectedSuppressedRules: ["data.no_uuid_primary_display"], + }, + ]; + + for (const testCase of cases) { + scanFileSource(testCase.file, testCase.source); + const caseFindings = findings.slice(beforeFindings).filter((finding) => finding.file === testCase.file); + const caseSuppressed = suppressedFindings.slice(beforeSuppressed).filter((finding) => finding.file === testCase.file); + for (const expectedRule of testCase.expectedRules) { + if (!caseFindings.some((finding) => finding.rule === expectedRule)) { + throw new Error(`[ui-design-rules][self-test] ${testCase.file} 未命中规则 ${expectedRule}`); + } + } + for (const expectedRule of testCase.expectedSuppressedRules) { + if (!caseSuppressed.some((finding) => finding.rule === expectedRule && finding.suppressionReason)) { + throw new Error(`[ui-design-rules][self-test] ${testCase.file} 未命中白名单 ${expectedRule}`); + } + } + } + + findings.splice(beforeFindings); + suppressedFindings.splice(beforeSuppressed); + console.log("[ui-design-rules][self-test] PASS"); +} + +if (selfTest) { + runSelfTest(); + process.exit(0); +} + const files = scanRoots.flatMap((scanRoot) => collectFiles(path.join(rootDir, scanRoot))); for (const file of files) { scanFile(file); @@ -322,18 +534,27 @@ const summary = { findings: findings.length, errors: findings.filter((finding) => finding.severity === "error").length, warnings: findings.filter((finding) => finding.severity === "warning").length, + suppressed: suppressedFindings.length, }; if (jsonOutput) { - console.log(JSON.stringify({ summary, findings }, null, 2)); + console.log(JSON.stringify({ summary, findings, suppressedFindings }, null, 2)); } else { - console.log(`[ui-design-rules] scanned ${summary.scannedFiles} files, ${summary.findings} findings (${summary.errors} errors, ${summary.warnings} warnings)`); + console.log(`[ui-design-rules] scanned ${summary.scannedFiles} files, ${summary.findings} findings (${summary.errors} errors, ${summary.warnings} warnings, ${summary.suppressed} suppressed)`); for (const finding of findings) { console.log(`${finding.severity.toUpperCase()} ${finding.rule} ${finding.file}:${finding.line} ${finding.message}`); if (finding.evidence) { console.log(` evidence: ${finding.evidence}`); } } + if (showSuppressed) { + for (const finding of suppressedFindings) { + console.log(`SUPPRESSED ${finding.rule} ${finding.file}:${finding.line} ${finding.suppressionReason}`); + if (finding.evidence) { + console.log(` evidence: ${finding.evidence}`); + } + } + } if (!findings.length) { console.log("[ui-design-rules] no findings"); } diff --git a/scripts/qa/run-business-flow-gates.sh b/scripts/qa/run-business-flow-gates.sh index 63f945ce..7182930c 100755 --- a/scripts/qa/run-business-flow-gates.sh +++ b/scripts/qa/run-business-flow-gates.sh @@ -8,6 +8,31 @@ REPORT_DIR="${BUSINESS_FLOW_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/business-flo mkdir -p "$REPORT_DIR" +write_summary() { + local exit_code="$?" + if [[ "$MODE" == "help" || "$MODE" == "-h" || "$MODE" == "--help" || "$MODE" == "list" ]]; then + return "$exit_code" + fi + if [[ "$MODE" == "local" && -f "$REPORT_DIR/summary.json" ]]; then + return "$exit_code" + fi + local status="PASS" + if [[ "$exit_code" -ne 0 ]]; then + status="FAIL" + fi + QA_REPORT_DIR="$REPORT_DIR" \ + QA_ISSUE_ID="${QA_ISSUE_ID:-}" \ + QA_BASE_URL="${E2E_BASE_URL:-}" \ + QA_MODE="business-flow-${MODE}" \ + QA_STATUS="$status" \ + QA_SUMMARY_NOTES="业务流门禁按 BUSINESS_FLOW_GROUPS 选择领域执行;summary.txt 保留逐组通过/失败明细。" \ + BUSINESS_FLOW_GROUPS="${BUSINESS_FLOW_GROUPS:-}" \ + node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true + return "$exit_code" +} + +trap write_summary EXIT + usage() { cat < diff --git a/scripts/qa/run-live-smoke-readiness.sh b/scripts/qa/run-live-smoke-readiness.sh index e04e4920..5509cc43 100755 --- a/scripts/qa/run-live-smoke-readiness.sh +++ b/scripts/qa/run-live-smoke-readiness.sh @@ -8,6 +8,24 @@ REPORT_DIR="${LIVE_SMOKE_READINESS_DIR:-$ROOT_DIR/artifacts/qa-flow/live-smoke-r mkdir -p "$REPORT_DIR" +write_summary() { + local exit_code="$?" + local status="PASS" + if [[ "$exit_code" -ne 0 ]]; then + status="FAIL" + fi + QA_REPORT_DIR="$REPORT_DIR" \ + QA_ISSUE_ID="${QA_ISSUE_ID:-}" \ + QA_BASE_URL="$PUBLIC_BASE_URL" \ + QA_MODE="live-smoke-readiness" \ + QA_STATUS="$status" \ + QA_SUMMARY_NOTES="公网 /ready、public bundle、pending OCR、live smoke 和真实 OCR 可用性检查;缺少凭据或样票时按脚本规则记录 SKIP。" \ + node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true + return "$exit_code" +} + +trap write_summary EXIT + has_live_smoke_credentials() { [[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] && [[ -n "${LIVE_SMOKE_ADMIN_PASSWORD:-}" ]] && diff --git a/scripts/qa/run-visual-matrix.sh b/scripts/qa/run-visual-matrix.sh new file mode 100755 index 00000000..ba39f788 --- /dev/null +++ b/scripts/qa/run-visual-matrix.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLIENT_DIR="$ROOT_DIR/client" +MODE="${1:-attached}" +ISSUE_ID="${QA_ISSUE_ID:-qg6-qa-governance-upgrade}" +REPORT_DIR="${VISUAL_MATRIX_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/${ISSUE_ID}-visual-matrix-$(date +%Y%m%d-%H%M%S)}" +SUMMARY_BASE_URL="${E2E_BASE_URL:-}" + +mkdir -p "$REPORT_DIR" + +usage() { + cat < + +说明: + local 准备本地临时库和服务后运行 PC + 移动视觉矩阵。 + attached 复用当前 E2E_BASE_URL 运行视觉矩阵。 + live 默认使用 https://oa.hyinda.com 运行公网视觉矩阵。 + +可选环境变量: + QA_ISSUE_ID 输出 issue-id,默认 qg6-qa-governance-upgrade + VISUAL_MATRIX_REPORT_DIR 摘要报告目录 + VISUAL_MATRIX_SCOPE=full|mobile attached 默认 full;live 默认 mobile,避免无凭据时写入公网测试数据 + E2E_BASE_URL attached 模式目标地址 +USAGE +} + +write_summary() { + local exit_code="$?" + if [[ "$MODE" == "help" || "$MODE" == "-h" || "$MODE" == "--help" ]]; then + return "$exit_code" + fi + if [[ -f "$REPORT_DIR/summary.json" ]]; then + return "$exit_code" + fi + local scope="${VISUAL_MATRIX_SCOPE:-}" + if [[ -z "$scope" ]]; then + if [[ "$MODE" == "live" ]]; then + scope="mobile" + else + scope="full" + fi + fi + local notes="移动 320/360/375/390/393/414/430;企业微信 iOS/Android UA 和普通移动 UA。" + if [[ "$scope" == "full" ]]; then + notes="PC 1440x960;移动 320/360/375/390/393/414/430;企业微信 iOS/Android UA 和普通移动 UA。" + fi + local status="PASS" + if [[ "$exit_code" -ne 0 ]]; then + status="FAIL" + fi + QA_REPORT_DIR="$REPORT_DIR" \ + QA_ISSUE_ID="$ISSUE_ID" \ + QA_BASE_URL="$SUMMARY_BASE_URL" \ + QA_MODE="visual-matrix-${MODE}" \ + QA_STATUS="$status" \ + QA_SUMMARY_NOTES="$notes" \ + node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true + return "$exit_code" +} +trap write_summary EXIT + +run_attached() { + local base_url="${1:-}" + local scope="${2:-full}" + if [[ -z "$base_url" ]]; then + echo "[visual-matrix][ERROR] attached 模式需要 E2E_BASE_URL" >&2 + exit 2 + fi + if [[ "$scope" != "full" && "$scope" != "mobile" ]]; then + echo "[visual-matrix][ERROR] VISUAL_MATRIX_SCOPE 只能是 full 或 mobile" >&2 + exit 2 + fi + SUMMARY_BASE_URL="$base_url" + + local specs=(tests/e2e/r6-mobile-unified-design-rollout.spec.ts) + if [[ "$scope" == "full" ]]; then + specs=(tests/e2e/project-wide-visual.spec.ts "${specs[@]}") + fi + + echo ">>> [visual-matrix] baseURL=$base_url issue=$ISSUE_ID scope=$scope" + ( + cd "$CLIENT_DIR" + E2E_BASE_URL="$base_url" \ + E2E_MODE=local \ + QA_ISSUE_ID="$ISSUE_ID" \ + QA_VISUAL_OUTPUT_DIR="$ROOT_DIR/output/playwright/${ISSUE_ID}/project-wide" \ + QA_VISUAL_ARTIFACT_DIR="$REPORT_DIR/project-wide" \ + MOBILE_VISUAL_OUTPUT_DIR="${ISSUE_ID}/mobile-matrix" \ + npx playwright test "${specs[@]}" --workers=1 --reporter=list + ) +} + +case "$MODE" in + help|-h|--help) + usage + ;; + local) + QA_ISSUE_ID="$ISSUE_ID" \ + VISUAL_MATRIX_REPORT_DIR="$REPORT_DIR" \ + bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" qg6-visual-matrix-local + ;; + attached) + run_attached "${E2E_BASE_URL:-}" "${VISUAL_MATRIX_SCOPE:-full}" + ;; + live) + run_attached "${E2E_BASE_URL:-https://oa.hyinda.com}" "${VISUAL_MATRIX_SCOPE:-mobile}" + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/scripts/qa/run-web-gates.sh b/scripts/qa/run-web-gates.sh index 1f7da849..5ad64bca 100755 --- a/scripts/qa/run-web-gates.sh +++ b/scripts/qa/run-web-gates.sh @@ -36,7 +36,7 @@ qa_require_cmd() { qa_usage() { cat < +Usage: bash scripts/qa/run-web-gates.sh 可选环境变量: QA_PORT / QA_HOST @@ -45,6 +45,7 @@ Usage: bash scripts/qa/run-web-gates.sh >> 执行 QG6 固定视觉矩阵" + ( + cd "$CLIENT_DIR" + rm -rf test-results playwright-report + E2E_BASE_URL="$QA_BASE_URL" \ + E2E_MODE=local \ + QA_ISSUE_ID="$issue_id" \ + QA_VISUAL_OUTPUT_DIR="$ROOT_DIR/output/playwright/${issue_id}/project-wide" \ + QA_VISUAL_ARTIFACT_DIR="$report_dir/project-wide" \ + MOBILE_VISUAL_OUTPUT_DIR="${issue_id}/mobile-matrix" \ + npx playwright test \ + tests/e2e/project-wide-visual.spec.ts \ + tests/e2e/r6-mobile-unified-design-rollout.spec.ts \ + --workers=1 \ + --reporter=list + ) + QA_REPORT_DIR="$report_dir" \ + QA_ISSUE_ID="$issue_id" \ + QA_BASE_URL="$QA_BASE_URL" \ + QA_MODE="visual-matrix-local" \ + QA_STATUS="PASS" \ + QA_SUMMARY_NOTES="PC 1440x960;移动 320/360/375/390/393/414/430;企业微信 iOS/Android UA 和普通移动 UA。" \ + node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" +} + qa_run_live_smoke() { local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" echo ">>> 探活 live /ready: ${live_base_url}/ready" @@ -584,6 +623,14 @@ case "$MODE" in trap qa_cleanup EXIT qa_run_project_wide_visual_local ;; + qg6-visual-matrix-local) + qa_require_cmd curl + qa_require_db_client + qa_require_cmd node + qa_require_cmd npm + trap qa_cleanup EXIT + qa_run_qg6_visual_matrix_local + ;; r70-assist-local) qa_require_cmd curl qa_require_db_client diff --git a/scripts/qa/write-qa-summary.mjs b/scripts/qa/write-qa-summary.mjs new file mode 100755 index 00000000..0baaa463 --- /dev/null +++ b/scripts/qa/write-qa-summary.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const rootDir = path.resolve(new URL("../..", import.meta.url).pathname); +const reportDir = process.env.QA_REPORT_DIR || process.argv[2]; + +if (!reportDir) { + console.error("Usage: QA_REPORT_DIR= node scripts/qa/write-qa-summary.mjs"); + process.exit(2); +} + +function currentCommit() { + if (process.env.QA_COMMIT?.trim()) { + return process.env.QA_COMMIT.trim(); + } + try { + return execSync("git rev-parse --short HEAD", { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unknown"; + } +} + +function optionalValue(value) { + return value?.trim() || null; +} + +const summary = { + issueId: optionalValue(process.env.QA_ISSUE_ID), + baseURL: optionalValue(process.env.QA_BASE_URL) || + optionalValue(process.env.E2E_BASE_URL) || + optionalValue(process.env.LIVE_SMOKE_BASE_URL), + commit: currentCommit(), + checkedAt: new Date().toISOString(), + status: optionalValue(process.env.QA_STATUS) || "UNKNOWN", + mode: optionalValue(process.env.QA_MODE), + reportDir: path.resolve(reportDir), + groups: optionalValue(process.env.BUSINESS_FLOW_GROUPS), + notes: optionalValue(process.env.QA_SUMMARY_NOTES), +}; + +mkdirSync(reportDir, { recursive: true }); +writeFileSync(path.join(reportDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8"); +console.log(`[qa-summary] ${summary.status} ${path.join(reportDir, "summary.json")}`);