QG6 完成QA治理矩阵与报告规范
这个提交包含在:
@@ -230,6 +230,7 @@ export function WorkflowTaskDetailPage() {
|
|||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
const canTransfer = allowedActions.has("transfer");
|
const canTransfer = allowedActions.has("transfer");
|
||||||
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
|
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
|
||||||
|
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold>
|
<PageScaffold>
|
||||||
@@ -239,9 +240,8 @@ export function WorkflowTaskDetailPage() {
|
|||||||
meta={(
|
meta={(
|
||||||
<>
|
<>
|
||||||
<StatusBadge status={detail.task.status} />
|
<StatusBadge status={detail.task.status} />
|
||||||
<span className="info-chip">任务ID:{detail.task.id}</span>
|
|
||||||
<span className="info-chip">对象类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
|
<span className="info-chip">对象类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
|
||||||
<span className="info-chip">实例:{detail.workflowInstance?.id ?? "-"}</span>
|
<span className="info-chip">业务状态:{businessStatusLabel}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
actions={(
|
actions={(
|
||||||
@@ -276,7 +276,7 @@ export function WorkflowTaskDetailPage() {
|
|||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="workflow-task-summary">
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="workflow-task-summary">
|
||||||
<div>状态:{workflowStatusLabel(detail.task.status)}</div>
|
<div>状态:{workflowStatusLabel(detail.task.status)}</div>
|
||||||
<div>实例:{detail.workflowInstance?.id ?? "-"}</div>
|
<div>业务状态:{businessStatusLabel}</div>
|
||||||
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
|
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
|
||||||
{detail.reimbursement ? (
|
{detail.reimbursement ? (
|
||||||
<>
|
<>
|
||||||
@@ -317,7 +317,7 @@ export function WorkflowTaskDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{detail.travelRequest ? (
|
{detail.travelRequest ? (
|
||||||
<>
|
<>
|
||||||
<div>差旅单号:{detail.travelRequest.requestNo ?? detail.travelRequest.id}</div>
|
<div>差旅单号:{detail.travelRequest.requestNo ?? "暂未生成"}</div>
|
||||||
<div>申请人:{detail.travelRequest.applicant?.name ?? "-"}</div>
|
<div>申请人:{detail.travelRequest.applicant?.name ?? "-"}</div>
|
||||||
<div>出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
<div>出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
||||||
<div>法体:{detail.travelRequest.legalEntity?.name ?? "-"}</div>
|
<div>法体:{detail.travelRequest.legalEntity?.name ?? "-"}</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||||
@@ -30,14 +31,41 @@ type PreparedRoutes = {
|
|||||||
|
|
||||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||||
const MATRIX_PATH = path.join(ROOT_DIR, "docs/trialrun/project-wide-flow-matrix.csv");
|
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 ISSUE_ID = process.env.QA_ISSUE_ID?.trim() || "project-wide-visual";
|
||||||
const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/qa-flow/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 =
|
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";
|
"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 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[] = [];
|
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) {
|
function outputPath(dir: string, fileName: string) {
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
return path.join(dir, fileName);
|
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()) {
|
for (const { user, plans: userPlans } of plansByUser.values()) {
|
||||||
const context = viewport === "mobile-wecom"
|
const context = viewport === "mobile-wecom"
|
||||||
? await newWecomContext(browser)
|
? await newWecomContext(browser)
|
||||||
: await browser.newContext({ viewport: { width: 1366, height: 900 }, serviceWorkers: "block" });
|
: await browser.newContext({ viewport: { width: 1440, height: 960 }, serviceWorkers: "block" });
|
||||||
try {
|
try {
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await loginForVisual(page, user);
|
await loginForVisual(page, user);
|
||||||
@@ -382,7 +410,15 @@ test.describe("项目级全页面视觉巡检", () => {
|
|||||||
|
|
||||||
const reportPath = outputPath(ARTIFACT_DIR, "report.json");
|
const reportPath = outputPath(ARTIFACT_DIR, "report.json");
|
||||||
writeFileSync(reportPath, JSON.stringify({
|
writeFileSync(reportPath, JSON.stringify({
|
||||||
|
issueId: ISSUE_ID,
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
commit: currentCommit(),
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
|
status: findings.some((finding) => finding.severity === "P1") ? "FAIL" : "PASS",
|
||||||
|
matrix: {
|
||||||
|
pc: "1440x960",
|
||||||
|
mobileWeCom: "390x844",
|
||||||
|
},
|
||||||
pcRoutes: pcPlans.map((item) => item.route),
|
pcRoutes: pcPlans.map((item) => item.route),
|
||||||
mobileRoutes: mobilePlans.map((item) => item.route),
|
mobileRoutes: mobilePlans.map((item) => item.route),
|
||||||
findings,
|
findings,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
| 项目 | 记录 |
|
| 项目 | 记录 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 公网地址 | `https://oa.hyinda.com` |
|
| 公网地址 | `https://oa.hyinda.com` |
|
||||||
| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6/LP6 后续优化已完成;ME6 自动化、部署和公网视觉复检已完成,`ME6-3` 代表性企业微信真机抽查因设备不便暂缓;真机暂缓期间可先做 QG6 非业务 QA 治理,但 QG6 不替代真机 |
|
| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6/LP6 后续优化已完成;ME6 自动化、部署和公网视觉复检已完成,`ME6-3` 代表性企业微信真机抽查因设备不便暂缓;QG6 非业务 QA 治理已完成但不替代真机 |
|
||||||
| 当前代码基线 | `96e5d2e`;ME6 移动二轮小修已部署到 `https://oa.hyinda.com`,并完成 `/ready`、public bundle、live smoke readiness 和公网移动视觉矩阵复检;真机抽查待用户确认 |
|
| 当前代码基线 | 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` |
|
| 试运行窗口 | `2026-05-12 14:00-17:00 Asia/Shanghai`;若现场无法执行,顺延到 `2026-05-13 14:00-17:00 Asia/Shanghai` |
|
||||||
| 回滚联系人 | `wangshunli` |
|
| 回滚联系人 | `wangshunli` |
|
||||||
| 问题登记负责人 | `wangshunli` |
|
| 问题登记负责人 | `wangshunli` |
|
||||||
@@ -100,6 +100,8 @@ ME6 自动化与部署收口(2026-05-12):移动二轮体验小修已完成
|
|||||||
|
|
||||||
ND9 非真机延续(2026-05-12):用户反馈当前不方便做代表性企业微信真机抽查,因此 `ME6-3` 继续保持 `TODO`,不得伪造完成或用自动化替代。后续可先执行 QG6 QA 治理、R7 草案、运维交接和培训材料等不依赖真机的工作;这些工作不得改变业务规则、审批链、PDF-only、企业微信通知通道或财务规则。R7 真实扩大试运行仍需用户确认最终人员、窗口,并补足 ME6 真机门禁或明确接受该风险。
|
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 定向修复后关闭。基于本地环境复测,经理团队日历与员工报销缺票提示/发票上传绑定提交链路均已通过,可恢复下一轮本地人工试运行。
|
R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。基于本地环境复测,经理团队日历与员工报销缺票提示/发票上传绑定提交链路均已通过,可恢复下一轮本地人工试运行。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
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-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-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/<issue-id>/,检查摘要报告。",playwright|node,TODO,TODO,TODO,client/tests/e2e|scripts/qa,"[""QG6-2""]",真实企业微信仍需代表性真机确认。
|
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/<issue-id>/,检查摘要报告。",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,TODO,TODO,TODO,scripts/qa|artifacts/qa-flow,"[""QG6-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,TODO,TODO,TODO,scripts/qa|client/tests/e2e|deploy/trialrun,"[""QG6-4""]",治理脚本变更不应修改业务行为。
|
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。
|
||||||
|
|||||||
|
@@ -6,6 +6,8 @@ import process from "node:process";
|
|||||||
const rootDir = process.cwd();
|
const rootDir = process.cwd();
|
||||||
const strict = process.argv.includes("--strict");
|
const strict = process.argv.includes("--strict");
|
||||||
const jsonOutput = process.argv.includes("--json");
|
const jsonOutput = process.argv.includes("--json");
|
||||||
|
const selfTest = process.argv.includes("--self-test");
|
||||||
|
const showSuppressed = process.argv.includes("--show-suppressed");
|
||||||
|
|
||||||
const scanRoots = [
|
const scanRoots = [
|
||||||
"client/src/components",
|
"client/src/components",
|
||||||
@@ -25,6 +27,14 @@ const userFacingBusinessTermFiles = new Set([
|
|||||||
]);
|
]);
|
||||||
const forbiddenUserFacingBusinessTerms = ["绑定整单", "不绑定明细", "发票池", "OCR"];
|
const forbiddenUserFacingBusinessTerms = ["绑定整单", "不绑定明细", "发票池", "OCR"];
|
||||||
const findings = [];
|
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) {
|
function toPosix(value) {
|
||||||
return value.split(path.sep).join("/");
|
return value.split(path.sep).join("/");
|
||||||
@@ -59,15 +69,29 @@ function lineNumber(source, index) {
|
|||||||
return source.slice(0, index).split("\n").length;
|
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 }) {
|
function addFinding({ rule, severity = "warning", file, line = 1, message, evidence }) {
|
||||||
findings.push({
|
const finding = {
|
||||||
rule,
|
rule,
|
||||||
severity,
|
severity,
|
||||||
file: toPosix(path.relative(rootDir, file)),
|
file: normalizeFindingFile(file),
|
||||||
line,
|
line,
|
||||||
message,
|
message,
|
||||||
evidence,
|
evidence,
|
||||||
});
|
};
|
||||||
|
const suppression = findSuppression(finding);
|
||||||
|
if (suppression) {
|
||||||
|
suppressedFindings.push({ ...finding, suppressionReason: suppression.reason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
findings.push(finding);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scanForbiddenCopy(file, source) {
|
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*</g,
|
||||||
|
message: "上传相关可见文案必须使用中文,避免暴露浏览器原生英文上传体验。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /(?:aria-label|title|placeholder)=["'](Choose File|No file chosen|Upload|Uploading|Browse)["']/g,
|
||||||
|
message: "上传相关 aria/title/placeholder 文案必须使用中文。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match = pattern.regex.exec(source);
|
||||||
|
while (match) {
|
||||||
|
addFinding({
|
||||||
|
rule: "copy.no_english_upload_labels",
|
||||||
|
severity: "error",
|
||||||
|
file,
|
||||||
|
line: lineNumber(source, match.index),
|
||||||
|
message: pattern.message,
|
||||||
|
evidence: match[0].replace(/\s+/g, " ").slice(0, 140),
|
||||||
|
});
|
||||||
|
match = pattern.regex.exec(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scanMobileCapturePdfInputs(file, source) {
|
function scanMobileCapturePdfInputs(file, source) {
|
||||||
const normalized = toPosix(path.relative(rootDir, file));
|
const normalized = toPosix(path.relative(rootDir, file));
|
||||||
if (!/client\/src\/pages\/Mobile.*\.tsx$/.test(normalized)) {
|
if (!/client\/src\/pages\/Mobile.*\.tsx$/.test(normalized)) {
|
||||||
@@ -176,6 +228,70 @@ function scanUserFacingBusinessTerms(file, source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobileSourceFile(file) {
|
||||||
|
const normalized = normalizeFindingFile(file);
|
||||||
|
return /^client\/src\/pages\/Mobile.*\.tsx$/.test(normalized) ||
|
||||||
|
/^client\/src\/components\/mobile\/.*\.(?:tsx|ts)$/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanMobileDesktopTables(file, source) {
|
||||||
|
if (!isMobileSourceFile(file)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
{
|
||||||
|
regex: /<\s*table\b|<Table\b/g,
|
||||||
|
evidence: "table",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /\btable-container\b|components\/ui\/table/g,
|
||||||
|
evidence: "table-container/ui-table",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match = pattern.regex.exec(source);
|
||||||
|
while (match) {
|
||||||
|
addFinding({
|
||||||
|
rule: "mobile.no_desktop_table",
|
||||||
|
severity: "error",
|
||||||
|
file,
|
||||||
|
line: lineNumber(source, match.index),
|
||||||
|
message: "专用 /mobile 页面不应引入桌面表格,应使用 mobile-list-card 或移动卡片列表。",
|
||||||
|
evidence: match[0],
|
||||||
|
});
|
||||||
|
match = pattern.regex.exec(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanMobilePcShell(file, source) {
|
||||||
|
if (!isMobileSourceFile(file)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
/AppShell|DesktopShell|Sidebar|NavLinks/g,
|
||||||
|
/备用模块|data-testid=["']sidebar["']|<aside\b/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const regex of patterns) {
|
||||||
|
let match = regex.exec(source);
|
||||||
|
while (match) {
|
||||||
|
addFinding({
|
||||||
|
rule: "mobile.no_pc_shell",
|
||||||
|
severity: "error",
|
||||||
|
file,
|
||||||
|
line: lineNumber(source, match.index),
|
||||||
|
message: "专用 /mobile 页面不应复用 PC 壳、侧栏或备用模块导航。",
|
||||||
|
evidence: match[0],
|
||||||
|
});
|
||||||
|
match = regex.exec(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scanRawBusinessIdentifiers(file, source) {
|
function scanRawBusinessIdentifiers(file, source) {
|
||||||
const normalized = toPosix(file);
|
const normalized = toPosix(file);
|
||||||
if (!/Reimbursement|Invoice|Leave|Purchase|Travel/.test(path.basename(normalized))) {
|
if (!/Reimbursement|Invoice|Leave|Purchase|Travel/.test(path.basename(normalized))) {
|
||||||
@@ -209,6 +325,38 @@ function scanRawBusinessIdentifiers(file, source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scanUuidPrimaryDisplay(file, source) {
|
||||||
|
const patterns = [
|
||||||
|
{
|
||||||
|
regex: /(?:任务|实例|记录|队列|员工|部门|审批|发票|报销|请假|采购|差旅|资产)?\s*ID\s*[::][^\n]{0,100}\{[^}\n]*(?:\.id\b|\bid\b)[^}\n]*\}/g,
|
||||||
|
message: "面向用户主展示不应直接显示内部 UUID,应使用业务编号、标题或摘要。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /(?:编号|单号)\s*[::][^\n]{0,100}\{[^}\n]*\?\?[^}\n]*(?:\.id\b|\bid\b)[^}\n]*\}/g,
|
||||||
|
message: "业务编号缺失时不应直接回退展示内部 UUID,应显示“暂未生成”或业务摘要。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /\{[^}\n]*\.id\.slice\(\s*0\s*,\s*(?:6|8|10)\s*\)[^}\n]*\}/g,
|
||||||
|
message: "面向用户页面不应展示截断 UUID 作为主信息。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match = pattern.regex.exec(source);
|
||||||
|
while (match) {
|
||||||
|
addFinding({
|
||||||
|
rule: "data.no_uuid_primary_display",
|
||||||
|
severity: "warning",
|
||||||
|
file,
|
||||||
|
line: lineNumber(source, match.index),
|
||||||
|
message: pattern.message,
|
||||||
|
evidence: match[0].replace(/\s+/g, " ").slice(0, 180),
|
||||||
|
});
|
||||||
|
match = pattern.regex.exec(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scanReimbursementRawCodeDisplay(file, source) {
|
function scanReimbursementRawCodeDisplay(file, source) {
|
||||||
const normalized = toPosix(path.relative(rootDir, file));
|
const normalized = toPosix(path.relative(rootDir, file));
|
||||||
if (normalized !== "client/src/pages/ReimbursementDetailPage.tsx") {
|
if (normalized !== "client/src/pages/ReimbursementDetailPage.tsx") {
|
||||||
@@ -300,18 +448,82 @@ function scanNavigationTitleCoverage(file, source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scanFile(file) {
|
function scanFileSource(file, source) {
|
||||||
const source = readFileSync(file, "utf8");
|
|
||||||
scanForbiddenCopy(file, source);
|
scanForbiddenCopy(file, source);
|
||||||
scanUserFacingBusinessTerms(file, source);
|
scanUserFacingBusinessTerms(file, source);
|
||||||
scanMobileDarkControls(file, source);
|
scanMobileDarkControls(file, source);
|
||||||
scanNativeFileInputs(file, source);
|
scanNativeFileInputs(file, source);
|
||||||
|
scanVisibleEnglishUploadCopy(file, source);
|
||||||
scanMobileCapturePdfInputs(file, source);
|
scanMobileCapturePdfInputs(file, source);
|
||||||
|
scanMobileDesktopTables(file, source);
|
||||||
|
scanMobilePcShell(file, source);
|
||||||
|
scanUuidPrimaryDisplay(file, source);
|
||||||
scanRawBusinessIdentifiers(file, source);
|
scanRawBusinessIdentifiers(file, source);
|
||||||
scanReimbursementRawCodeDisplay(file, source);
|
scanReimbursementRawCodeDisplay(file, source);
|
||||||
scanNavigationTitleCoverage(file, source);
|
scanNavigationTitleCoverage(file, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scanFile(file) {
|
||||||
|
scanFileSource(file, readFileSync(file, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSelfTest() {
|
||||||
|
const beforeFindings = findings.length;
|
||||||
|
const beforeSuppressed = suppressedFindings.length;
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
file: "client/src/pages/MobileBadFixture.tsx",
|
||||||
|
source: `
|
||||||
|
export function MobileBadFixture() {
|
||||||
|
return <div><table><tbody /></table><aside>备用模块</aside><button>Upload</button><div>任务ID:{task.id}</div></div>;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
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 <div>记录ID:{item.id}</div>;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
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)));
|
const files = scanRoots.flatMap((scanRoot) => collectFiles(path.join(rootDir, scanRoot)));
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
scanFile(file);
|
scanFile(file);
|
||||||
@@ -322,18 +534,27 @@ const summary = {
|
|||||||
findings: findings.length,
|
findings: findings.length,
|
||||||
errors: findings.filter((finding) => finding.severity === "error").length,
|
errors: findings.filter((finding) => finding.severity === "error").length,
|
||||||
warnings: findings.filter((finding) => finding.severity === "warning").length,
|
warnings: findings.filter((finding) => finding.severity === "warning").length,
|
||||||
|
suppressed: suppressedFindings.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (jsonOutput) {
|
if (jsonOutput) {
|
||||||
console.log(JSON.stringify({ summary, findings }, null, 2));
|
console.log(JSON.stringify({ summary, findings, suppressedFindings }, null, 2));
|
||||||
} else {
|
} 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) {
|
for (const finding of findings) {
|
||||||
console.log(`${finding.severity.toUpperCase()} ${finding.rule} ${finding.file}:${finding.line} ${finding.message}`);
|
console.log(`${finding.severity.toUpperCase()} ${finding.rule} ${finding.file}:${finding.line} ${finding.message}`);
|
||||||
if (finding.evidence) {
|
if (finding.evidence) {
|
||||||
console.log(` evidence: ${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) {
|
if (!findings.length) {
|
||||||
console.log("[ui-design-rules] no findings");
|
console.log("[ui-design-rules] no findings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ REPORT_DIR="${BUSINESS_FLOW_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/business-flo
|
|||||||
|
|
||||||
mkdir -p "$REPORT_DIR"
|
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() {
|
usage() {
|
||||||
cat <<USAGE
|
cat <<USAGE
|
||||||
Usage: bash scripts/qa/run-business-flow-gates.sh <local|attached|list>
|
Usage: bash scripts/qa/run-business-flow-gates.sh <local|attached|list>
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ REPORT_DIR="${LIVE_SMOKE_READINESS_DIR:-$ROOT_DIR/artifacts/qa-flow/live-smoke-r
|
|||||||
|
|
||||||
mkdir -p "$REPORT_DIR"
|
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() {
|
has_live_smoke_credentials() {
|
||||||
[[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
|
[[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
|
||||||
[[ -n "${LIVE_SMOKE_ADMIN_PASSWORD:-}" ]] &&
|
[[ -n "${LIVE_SMOKE_ADMIN_PASSWORD:-}" ]] &&
|
||||||
|
|||||||
+115
@@ -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 <<USAGE
|
||||||
|
Usage: bash scripts/qa/run-visual-matrix.sh <local|attached|live>
|
||||||
|
|
||||||
|
说明:
|
||||||
|
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
|
||||||
+48
-1
@@ -36,7 +36,7 @@ qa_require_cmd() {
|
|||||||
|
|
||||||
qa_usage() {
|
qa_usage() {
|
||||||
cat <<USAGE
|
cat <<USAGE
|
||||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|business-flow-local|project-wide-visual-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
|
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|business-flow-local|project-wide-visual-local|qg6-visual-matrix-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
|
||||||
|
|
||||||
可选环境变量:
|
可选环境变量:
|
||||||
QA_PORT / QA_HOST
|
QA_PORT / QA_HOST
|
||||||
@@ -45,6 +45,7 @@ Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|business-flow-lo
|
|||||||
QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1
|
QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1
|
||||||
QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
|
QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
|
||||||
QA_SKIP_CLIENT_BUILD=1 复用已有 client/dist(低内存远程机排查视觉问题时使用)
|
QA_SKIP_CLIENT_BUILD=1 复用已有 client/dist(低内存远程机排查视觉问题时使用)
|
||||||
|
QA_ISSUE_ID / VISUAL_MATRIX_REPORT_DIR
|
||||||
LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
|
LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
@@ -538,6 +539,44 @@ qa_run_project_wide_visual_local() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qa_run_qg6_visual_matrix_local() {
|
||||||
|
qa_select_port
|
||||||
|
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1536}"
|
||||||
|
export JOBS_ENABLED="${JOBS_ENABLED:-true}"
|
||||||
|
qa_export_app_env
|
||||||
|
qa_prepare_db
|
||||||
|
qa_build_client
|
||||||
|
qa_start_server
|
||||||
|
|
||||||
|
local issue_id="${QA_ISSUE_ID:-qg6-qa-governance-upgrade}"
|
||||||
|
local report_dir="${VISUAL_MATRIX_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/${issue_id}-visual-matrix-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
mkdir -p "$report_dir"
|
||||||
|
|
||||||
|
echo ">>> 执行 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() {
|
qa_run_live_smoke() {
|
||||||
local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||||
echo ">>> 探活 live /ready: ${live_base_url}/ready"
|
echo ">>> 探活 live /ready: ${live_base_url}/ready"
|
||||||
@@ -584,6 +623,14 @@ case "$MODE" in
|
|||||||
trap qa_cleanup EXIT
|
trap qa_cleanup EXIT
|
||||||
qa_run_project_wide_visual_local
|
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)
|
r70-assist-local)
|
||||||
qa_require_cmd curl
|
qa_require_cmd curl
|
||||||
qa_require_db_client
|
qa_require_db_client
|
||||||
|
|||||||
+50
@@ -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=<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")}`);
|
||||||
在新工单中引用
屏蔽一个用户