QG6 完成QA治理矩阵与报告规范

这个提交包含在:
2026-05-12 17:47:25 +08:00
父节点 4a2e6fc71a
当前提交 bc03e9946c
共修改 10 个文件,包含 535 行新增和 21 行删除
+228 -7
查看文件
@@ -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*</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) {
const normalized = toPosix(path.relative(rootDir, file));
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) {
const normalized = toPosix(file);
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) {
const normalized = toPosix(path.relative(rootDir, file));
if (normalized !== "client/src/pages/ReimbursementDetailPage.tsx") {
@@ -300,18 +448,82 @@ function scanNavigationTitleCoverage(file, source) {
}
}
function scanFile(file) {
const source = readFileSync(file, "utf8");
function scanFileSource(file, source) {
scanForbiddenCopy(file, source);
scanUserFacingBusinessTerms(file, source);
scanMobileDarkControls(file, source);
scanNativeFileInputs(file, source);
scanVisibleEnglishUploadCopy(file, source);
scanMobileCapturePdfInputs(file, source);
scanMobileDesktopTables(file, source);
scanMobilePcShell(file, source);
scanUuidPrimaryDisplay(file, source);
scanRawBusinessIdentifiers(file, source);
scanReimbursementRawCodeDisplay(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)));
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");
}
+25
查看文件
@@ -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 <<USAGE
Usage: bash scripts/qa/run-business-flow-gates.sh <local|attached|list>
+18
查看文件
@@ -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:-}" ]] &&
+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() {
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
@@ -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_PREFIX / QA_DB_NAME / QA_KEEP_DB
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
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() {
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
+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")}`);