B1-B8 建立项目级流程与QA门禁
这个提交包含在:
+387
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const rootDir = path.resolve(new URL("../..", import.meta.url).pathname);
|
||||
const routerPath = path.join(rootDir, "client/src/router/index.tsx");
|
||||
const navSchemaPath = path.join(rootDir, "client/src/features/navigation/schema.ts");
|
||||
const flowMatrixPath = path.join(rootDir, "docs/trialrun/project-wide-flow-matrix.csv");
|
||||
const serverRoutesDir = path.join(rootDir, "server/src/routes");
|
||||
const e2eDir = path.join(rootDir, "client/tests/e2e");
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: node scripts/qa/check-flow-coverage.mjs [--json] [--markdown <path>] [--fail-on-gaps]
|
||||
|
||||
输出:
|
||||
--json 输出 JSON 到 stdout
|
||||
--markdown <path> 输出 Markdown 摘要到指定路径
|
||||
--fail-on-gaps 存在 P0 missing_e2e 或 missing_visual 时以 1 退出
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed = {
|
||||
json: false,
|
||||
markdownPath: "",
|
||||
failOnGaps: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--json") {
|
||||
parsed.json = true;
|
||||
} else if (arg === "--markdown") {
|
||||
parsed.markdownPath = args[index + 1] ?? "";
|
||||
index += 1;
|
||||
} else if (arg === "--fail-on-gaps") {
|
||||
parsed.failOnGaps = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
usage();
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`未知参数:${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.includes("--markdown") && !parsed.markdownPath) {
|
||||
throw new Error("--markdown 需要指定输出路径");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readText(file) {
|
||||
return readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function walkFiles(dir, predicate = () => true) {
|
||||
const output = [];
|
||||
if (!existsSync(dir)) return output;
|
||||
|
||||
for (const name of readdirSync(dir)) {
|
||||
const file = path.join(dir, name);
|
||||
const stat = statSync(file);
|
||||
if (stat.isDirectory()) {
|
||||
output.push(...walkFiles(file, predicate));
|
||||
} else if (predicate(file)) {
|
||||
output.push(file);
|
||||
}
|
||||
}
|
||||
return output.sort();
|
||||
}
|
||||
|
||||
function toPosix(file) {
|
||||
return file.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const cells = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (quoted && char === "\"" && next === "\"") {
|
||||
current += "\"";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && char === ",") {
|
||||
cells.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
cells.push(current);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseCsv(source) {
|
||||
const lines = source.split(/\r?\n/).filter((line) => line.trim());
|
||||
const header = parseCsvLine(lines[0] ?? "");
|
||||
return lines.slice(1).map((line) => {
|
||||
const cells = parseCsvLine(line);
|
||||
return Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""]));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoute(route) {
|
||||
if (!route) return "/";
|
||||
if (route === "*") return "*";
|
||||
const prefixed = route.startsWith("/") ? route : `/${route}`;
|
||||
return prefixed.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
||||
}
|
||||
|
||||
function routeToKeyword(route) {
|
||||
return normalizeRoute(route)
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/:[^/]+/g, "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function routeToRegex(route) {
|
||||
const normalized = normalizeRoute(route);
|
||||
const escaped = normalized
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\\:[^/]+/g, "[^/]+");
|
||||
return new RegExp(`(^|["'\`\\s])${escaped}($|["'\`?#\\s])`);
|
||||
}
|
||||
|
||||
function parseRouterRoutes() {
|
||||
const source = readText(routerPath);
|
||||
const routeRegex = /<Route\s+([^>]*?)>/gs;
|
||||
const routes = [];
|
||||
let match = routeRegex.exec(source);
|
||||
while (match) {
|
||||
const attrs = match[1];
|
||||
const pathMatch = attrs.match(/\bpath=(?:"([^"]+)"|\{`([^`]+)`\})/);
|
||||
const indexRoute = /\bindex\b/.test(attrs);
|
||||
if (pathMatch || indexRoute) {
|
||||
const rawPath = pathMatch?.[1] ?? pathMatch?.[2] ?? "/";
|
||||
const normalized = indexRoute && !pathMatch ? "/" : normalizeRoute(rawPath);
|
||||
if (normalized !== "*" && normalized !== "/403") {
|
||||
routes.push({ path: normalized });
|
||||
}
|
||||
}
|
||||
match = routeRegex.exec(source);
|
||||
}
|
||||
|
||||
return [...new Map(routes.map((route) => [route.path, route])).values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
function parsePageTitles() {
|
||||
const source = existsSync(navSchemaPath) ? readText(navSchemaPath) : "";
|
||||
const titleRegex = /\{\s*path:\s*"([^"]+)",\s*title:\s*"([^"]+)",\s*group:\s*"([^"]+)"/g;
|
||||
const titles = new Map();
|
||||
let match = titleRegex.exec(source);
|
||||
while (match) {
|
||||
titles.set(normalizeRoute(match[1]), { title: match[2], group: match[3] });
|
||||
match = titleRegex.exec(source);
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
function parseSpecs() {
|
||||
return walkFiles(e2eDir, (file) => file.endsWith(".spec.ts")).map((file) => {
|
||||
const relPath = toPosix(path.relative(rootDir, file));
|
||||
const source = readText(file);
|
||||
const lower = `${relPath}\n${source}`.toLowerCase();
|
||||
return {
|
||||
path: relPath,
|
||||
source,
|
||||
lower,
|
||||
isMobile: /mobile|\/mobile|移动/.test(lower),
|
||||
isWecom: /wecom|wxwork|企业微信|weixinwork/.test(lower),
|
||||
isVisual: /visual|screenshot|tohavescreenshot|视觉/.test(lower),
|
||||
isFlow: /login\(|createauthenticatedapiclient|api(post|get|patch)data|workflow|approve|submit|上传|审批|报销|请假/.test(lower),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseServerRoutes() {
|
||||
return walkFiles(serverRoutesDir, (file) => file.endsWith(".js")).map((file) => toPosix(path.relative(rootDir, file)));
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return value.split("|").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function routeTokenMatches(pattern, route) {
|
||||
const normalizedPattern = normalizeRoute(pattern);
|
||||
const normalizedRoute = normalizeRoute(route);
|
||||
if (normalizedPattern === normalizedRoute) return true;
|
||||
if (normalizedPattern.includes(":")) {
|
||||
return routeToRegex(normalizedPattern).test(normalizedRoute);
|
||||
}
|
||||
return normalizedRoute === normalizedPattern || normalizedRoute.startsWith(`${normalizedPattern}/`);
|
||||
}
|
||||
|
||||
function domainForRoute(route, domains) {
|
||||
return domains.find((domain) => {
|
||||
const routeTokens = [
|
||||
...splitList(domain.PC_Routes ?? ""),
|
||||
...splitList(domain.Mobile_WeCom_Routes ?? ""),
|
||||
];
|
||||
return routeTokens.some((token) => routeTokenMatches(token, route));
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function specMatchesRoute(spec, route, domain) {
|
||||
const normalized = normalizeRoute(route);
|
||||
const keyword = routeToKeyword(normalized);
|
||||
const routeRegex = routeToRegex(normalized);
|
||||
if (routeRegex.test(spec.source)) return true;
|
||||
|
||||
if (keyword && keyword.split(" ").some((part) => part.length >= 5 && spec.lower.includes(part.toLowerCase()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!domain) return false;
|
||||
const domainKeywords = [
|
||||
domain.Domain,
|
||||
...splitList(domain.PC_Routes ?? ""),
|
||||
...splitList(domain.Mobile_WeCom_Routes ?? ""),
|
||||
...splitList(domain.Server_Routes ?? "").map((item) => path.basename(item, ".js")),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.split(/[\s/|:_-]+/)
|
||||
.filter((item) => item.length >= 4);
|
||||
|
||||
return domainKeywords.some((part) => spec.lower.includes(part));
|
||||
}
|
||||
|
||||
function classifyRoute(route, specs, domain, titleMeta) {
|
||||
const matchedSpecs = specs.filter((spec) => specMatchesRoute(spec, route.path, domain));
|
||||
const coverage = {
|
||||
pc: matchedSpecs.some((spec) => !spec.isMobile && !route.path.startsWith("/mobile")),
|
||||
mobile: matchedSpecs.some((spec) => spec.isMobile) || route.path.startsWith("/mobile"),
|
||||
wecom: matchedSpecs.some((spec) => spec.isWecom),
|
||||
visual: matchedSpecs.some((spec) => spec.isVisual),
|
||||
flow: matchedSpecs.some((spec) => spec.isFlow),
|
||||
};
|
||||
const gaps = [];
|
||||
const isP0 = domain?.Tier === "P0";
|
||||
if (!matchedSpecs.length) gaps.push("missing_e2e");
|
||||
if (route.path.startsWith("/mobile") && !coverage.wecom) gaps.push("missing_wecom_assertion");
|
||||
if (isP0 && !coverage.visual) gaps.push("missing_visual");
|
||||
if (isP0 && !coverage.flow) gaps.push("missing_flow");
|
||||
|
||||
return {
|
||||
path: route.path,
|
||||
title: titleMeta?.title ?? "",
|
||||
group: titleMeta?.group ?? "",
|
||||
domain: domain?.Domain ?? "未映射",
|
||||
tier: domain?.Tier ?? "unmapped",
|
||||
coverage,
|
||||
specs: matchedSpecs.map((spec) => spec.path),
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(routes, domains, serverRoutes) {
|
||||
const gapCounts = new Map();
|
||||
for (const route of routes) {
|
||||
for (const gap of route.gaps) {
|
||||
gapCounts.set(gap, (gapCounts.get(gap) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const domainRows = domains.map((domain) => {
|
||||
const domainRoutes = routes.filter((route) => route.domain === domain.Domain);
|
||||
return {
|
||||
domain: domain.Domain,
|
||||
tier: domain.Tier,
|
||||
routeCount: domainRoutes.length,
|
||||
gapCount: domainRoutes.reduce((total, route) => total + route.gaps.length, 0),
|
||||
routes: domainRoutes.map((route) => route.path),
|
||||
knownGaps: domain.Known_Gaps,
|
||||
nextAction: domain.Next_Action,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
routeCount: routes.length,
|
||||
domainCount: domains.length,
|
||||
serverRouteCount: serverRoutes.length,
|
||||
gapCounts: Object.fromEntries([...gapCounts.entries()].sort()),
|
||||
highRiskGapCount: routes.filter((route) => route.tier === "P0" && route.gaps.length).length,
|
||||
},
|
||||
domains: domainRows,
|
||||
routes,
|
||||
serverRoutes,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const gapSummary = Object.entries(report.summary.gapCounts)
|
||||
.map(([gap, count]) => `- ${gap}: ${count}`)
|
||||
.join("\n") || "- none: 0";
|
||||
|
||||
const routeRows = report.routes
|
||||
.map((route) => `| ${route.path} | ${route.domain} | ${route.tier} | ${route.coverage.pc ? "Y" : "N"} | ${route.coverage.mobile ? "Y" : "N"} | ${route.coverage.wecom ? "Y" : "N"} | ${route.coverage.visual ? "Y" : "N"} | ${route.coverage.flow ? "Y" : "N"} | ${route.gaps.join("<br>") || "-"} |`)
|
||||
.join("\n");
|
||||
|
||||
const domainRows = report.domains
|
||||
.map((domain) => `| ${domain.domain} | ${domain.tier} | ${domain.routeCount} | ${domain.gapCount} | ${domain.nextAction || "-"} |`)
|
||||
.join("\n");
|
||||
|
||||
return `# 项目级流程覆盖扫描
|
||||
|
||||
- 生成时间:${report.generatedAt}
|
||||
- 前端路由数:${report.summary.routeCount}
|
||||
- 业务域数:${report.summary.domainCount}
|
||||
- 后端 route 文件数:${report.summary.serverRouteCount}
|
||||
- P0 含缺口路由数:${report.summary.highRiskGapCount}
|
||||
|
||||
## 缺口汇总
|
||||
|
||||
${gapSummary}
|
||||
|
||||
## 业务域
|
||||
|
||||
| Domain | Tier | Routes | Gaps | Next Action |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
${domainRows}
|
||||
|
||||
## 路由覆盖
|
||||
|
||||
| Route | Domain | Tier | PC | Mobile | WeCom | Visual | Flow | Gaps |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
${routeRows}
|
||||
`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs();
|
||||
const domains = parseCsv(readText(flowMatrixPath));
|
||||
const routes = parseRouterRoutes();
|
||||
const titles = parsePageTitles();
|
||||
const specs = parseSpecs();
|
||||
const serverRoutes = parseServerRoutes();
|
||||
const classifiedRoutes = routes.map((route) => {
|
||||
const domain = domainForRoute(route.path, domains);
|
||||
const titleMeta = titles.get(route.path) ?? titles.get(`${route.path}/`);
|
||||
return classifyRoute(route, specs, domain, titleMeta);
|
||||
});
|
||||
const report = summarize(classifiedRoutes, domains, serverRoutes);
|
||||
|
||||
if (args.markdownPath) {
|
||||
const outputPath = path.resolve(rootDir, args.markdownPath);
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, renderMarkdown(report), "utf8");
|
||||
console.error(`[flow-coverage] markdown=${outputPath}`);
|
||||
}
|
||||
|
||||
if (args.json || !args.markdownPath) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
if (args.failOnGaps) {
|
||||
const hasBlockingGap = report.routes.some(
|
||||
(route) => route.tier === "P0" && (route.gaps.includes("missing_e2e") || route.gaps.includes("missing_visual")),
|
||||
);
|
||||
if (hasBlockingGap) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`[flow-coverage][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
|
||||
|
||||
function present(key) {
|
||||
return Boolean(process.env[key]?.trim());
|
||||
}
|
||||
|
||||
function maskedPresence(keys) {
|
||||
return Object.fromEntries(keys.map((key) => [key, present(key)]));
|
||||
}
|
||||
|
||||
function existingFileFrom(keys) {
|
||||
const values = keys.map((key) => process.env[key]?.trim()).filter(Boolean);
|
||||
return values.find((value) => existsSync(value)) || "";
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, json, text: text.slice(0, 300) };
|
||||
} catch (error) {
|
||||
return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function buildStaticChecks() {
|
||||
const liveSmokeKeys = [
|
||||
"LIVE_SMOKE_ADMIN_IDENTIFIER",
|
||||
"LIVE_SMOKE_ADMIN_PASSWORD",
|
||||
"LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
|
||||
"LIVE_SMOKE_EMPLOYEE_PASSWORD",
|
||||
];
|
||||
const liveSmokeReady = liveSmokeKeys.every(present);
|
||||
const ocrUserReady = (
|
||||
(present("PLAYWRIGHT_OCR_LIVE_IDENTIFIER") && present("PLAYWRIGHT_OCR_LIVE_PASSWORD")) ||
|
||||
(present("LIVE_SMOKE_EMPLOYEE_IDENTIFIER") && present("LIVE_SMOKE_EMPLOYEE_PASSWORD"))
|
||||
);
|
||||
const invoiceFile = existingFileFrom(["PLAYWRIGHT_OCR_LIVE_FILE", "LIVE_OCR_INVOICE_FILE"]);
|
||||
const invoiceBatchDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim() || "";
|
||||
const invoiceSampleReady = Boolean(invoiceFile) || (Boolean(invoiceBatchDir) && existsSync(invoiceBatchDir));
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
liveSmokeReady,
|
||||
liveSmoke: maskedPresence(liveSmokeKeys),
|
||||
liveSmokeOptional: maskedPresence([
|
||||
"LIVE_EXPECT_WECOM_STATUS",
|
||||
"LIVE_SMOKE_MANAGER_IDENTIFIER",
|
||||
"LIVE_SMOKE_MANAGER_PASSWORD",
|
||||
"LIVE_SMOKE_FINANCE_IDENTIFIER",
|
||||
"LIVE_SMOKE_FINANCE_PASSWORD",
|
||||
]),
|
||||
liveOcrReady: ocrUserReady && invoiceSampleReady,
|
||||
liveOcr: {
|
||||
userReady: ocrUserReady,
|
||||
invoiceSampleReady,
|
||||
invoiceFileProvided: Boolean(invoiceFile),
|
||||
invoiceBatchDirProvided: Boolean(invoiceBatchDir),
|
||||
allowDuplicate: process.env.PLAYWRIGHT_OCR_ALLOW_DUPLICATE === "1",
|
||||
},
|
||||
missing: [],
|
||||
};
|
||||
}
|
||||
|
||||
function addMissing(checks) {
|
||||
if (!checks.liveSmokeReady) {
|
||||
for (const [key, value] of Object.entries(checks.liveSmoke)) {
|
||||
if (!value) checks.missing.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!checks.liveOcr.userReady) {
|
||||
checks.missing.push("PLAYWRIGHT_OCR_LIVE_IDENTIFIER/PASSWORD 或 LIVE_SMOKE_EMPLOYEE_*");
|
||||
}
|
||||
if (!checks.liveOcr.invoiceSampleReady) {
|
||||
checks.missing.push("PLAYWRIGHT_OCR_LIVE_FILE 或 PLAYWRIGHT_OCR_LIVE_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const strict = process.argv.includes("--strict");
|
||||
const jsonOnly = process.argv.includes("--json");
|
||||
const checks = buildStaticChecks();
|
||||
addMissing(checks);
|
||||
|
||||
const ready = await fetchJson(`${baseUrl}/ready`);
|
||||
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
|
||||
const result = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
...checks,
|
||||
publicReady: {
|
||||
ok: ready.ok,
|
||||
status: ready.status,
|
||||
},
|
||||
wecomConfig: {
|
||||
ok: wecomConfig.ok,
|
||||
status: wecomConfig.status,
|
||||
statusText: wecomConfig.json?.data?.status ?? null,
|
||||
entryEnabled: wecomConfig.json?.data?.entryEnabled ?? null,
|
||||
oauthEnabled: wecomConfig.json?.data?.oauthEnabled ?? null,
|
||||
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null,
|
||||
loginUrl: wecomConfig.json?.data?.loginUrl ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
if (jsonOnly) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`[live-smoke-env] base=${result.baseUrl}`);
|
||||
console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`);
|
||||
console.log(`[live-smoke-env] wecom=${result.wecomConfig.statusText ?? "unknown"} entry=${result.wecomConfig.entryEnabled} oauth=${result.wecomConfig.oauthEnabled}`);
|
||||
console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`);
|
||||
console.log(`[live-smoke-env] liveOcrReady=${result.liveOcrReady}`);
|
||||
if (result.missing.length) {
|
||||
console.log(`[live-smoke-env] missing=${result.missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[live-smoke-env][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SERVER_DIR="$ROOT_DIR/server"
|
||||
REPORT_DIR="${MIGRATION_DRIFT_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/migration-drift-$(date +%Y%m%d-%H%M%S)}"
|
||||
STATUS_LOG="$REPORT_DIR/prisma-migrate-status.txt"
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
echo ">>> [migration-drift] Prisma migrate status(只读)"
|
||||
set +e
|
||||
(
|
||||
cd "$SERVER_DIR"
|
||||
npx prisma migrate status
|
||||
) >"$STATUS_LOG" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
cat "$STATUS_LOG"
|
||||
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
echo "[migration-drift][PASS] 当前 DATABASE_URL 对应数据库与迁移目录一致"
|
||||
echo "[migration-drift] report=$STATUS_LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[migration-drift][WARN] Prisma migrate status 返回非 0:$status"
|
||||
echo "[migration-drift][WARN] 未执行 migrate reset,也未修改数据库。"
|
||||
echo "[migration-drift][WARN] 安全替代回归命令:bash scripts/qa/run-isolated-regression-dedup.sh"
|
||||
echo "[migration-drift][WARN] 若确需重建本地开发库,必须先人工确认备份与影响范围。"
|
||||
echo "[migration-drift] report=$STATUS_LOG"
|
||||
|
||||
if [[ "${MIGRATION_DRIFT_STRICT:-0}" == "1" ]]; then
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
MODE="${1:-local}"
|
||||
REPORT_DIR="${BUSINESS_FLOW_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/business-flow-$(date +%Y%m%d-%H%M%S)}"
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-business-flow-gates.sh <local|attached|list>
|
||||
|
||||
说明:
|
||||
local 通过 scripts/qa/run-web-gates.sh business-flow-local 准备临时库和服务后运行
|
||||
attached 复用当前 E2E_BASE_URL 直接按业务域执行 Playwright
|
||||
list 仅输出业务域与用例清单
|
||||
|
||||
可选环境变量:
|
||||
BUSINESS_FLOW_GROUPS=auth,reimbursement 仅运行部分业务域
|
||||
E2E_BASE_URL attached 模式下必填或沿用 Playwright 默认
|
||||
USAGE
|
||||
}
|
||||
|
||||
declare -a GROUP_ORDER=(
|
||||
auth
|
||||
reimbursement
|
||||
approval
|
||||
leave
|
||||
collaboration
|
||||
hr
|
||||
project
|
||||
procurement
|
||||
asset
|
||||
travel
|
||||
governance
|
||||
)
|
||||
|
||||
specs_for_group() {
|
||||
case "$1" in
|
||||
auth)
|
||||
printf '%s\n' \
|
||||
tests/e2e/login-portal-ux.spec.ts \
|
||||
tests/e2e/wecom-entry.spec.ts \
|
||||
tests/e2e/wecom-first-bind.spec.ts \
|
||||
tests/e2e/r89-mobile-login-navigation.spec.ts
|
||||
;;
|
||||
reimbursement)
|
||||
printf '%s\n' \
|
||||
tests/e2e/reimbursement-workflow-progress.spec.ts \
|
||||
tests/e2e/invoice-ocr-flow.spec.ts \
|
||||
tests/e2e/reimbursement-payment-batch-flow.spec.ts \
|
||||
tests/e2e/r102-reimbursement-main-flow.spec.ts \
|
||||
tests/e2e/r111-invoice-reimbursement-guard.spec.ts \
|
||||
tests/e2e/r125-invoice-reupload-after-delete.spec.ts
|
||||
;;
|
||||
approval)
|
||||
printf '%s\n' \
|
||||
tests/e2e/workflow-quick-approve.spec.ts \
|
||||
tests/e2e/mobile-approvals-offline.spec.ts
|
||||
;;
|
||||
leave)
|
||||
printf '%s\n' \
|
||||
tests/e2e/leave-flow.spec.ts \
|
||||
tests/e2e/leave-team-calendar.spec.ts
|
||||
;;
|
||||
collaboration)
|
||||
printf '%s\n' \
|
||||
tests/e2e/weekly-todo-flow.spec.ts \
|
||||
tests/e2e/announcements-presence.spec.ts \
|
||||
tests/e2e/global-search.spec.ts
|
||||
;;
|
||||
hr)
|
||||
printf '%s\n' \
|
||||
tests/e2e/user-onboarding.spec.ts \
|
||||
tests/e2e/r75-automation-gap-fill.spec.ts
|
||||
;;
|
||||
project)
|
||||
printf '%s\n' \
|
||||
tests/e2e/project-workbench.spec.ts \
|
||||
tests/e2e/confidential-boundary.spec.ts
|
||||
;;
|
||||
procurement)
|
||||
printf '%s\n' \
|
||||
tests/e2e/procurement-flow.spec.ts
|
||||
;;
|
||||
asset)
|
||||
printf '%s\n' \
|
||||
tests/e2e/assets-flow.spec.ts
|
||||
;;
|
||||
travel)
|
||||
printf '%s\n' \
|
||||
tests/e2e/travel-payment-flow.spec.ts \
|
||||
tests/e2e/travel-reconciliation-flow.spec.ts
|
||||
;;
|
||||
governance)
|
||||
printf '%s\n' \
|
||||
tests/e2e/settings-governance.spec.ts
|
||||
;;
|
||||
*)
|
||||
echo "[business-flow][ERROR] 未知业务域:$1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
selected_groups() {
|
||||
if [[ -n "${BUSINESS_FLOW_GROUPS:-}" ]]; then
|
||||
tr ',' '\n' <<<"$BUSINESS_FLOW_GROUPS" | sed '/^$/d'
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s\n' "${GROUP_ORDER[@]}"
|
||||
}
|
||||
|
||||
existing_specs_for_group() {
|
||||
local group="$1"
|
||||
local spec
|
||||
while IFS= read -r spec; do
|
||||
[[ -f "$CLIENT_DIR/$spec" ]] && printf '%s\n' "$spec"
|
||||
done < <(specs_for_group "$group")
|
||||
}
|
||||
|
||||
list_groups() {
|
||||
local group
|
||||
for group in "${GROUP_ORDER[@]}"; do
|
||||
echo "[$group]"
|
||||
existing_specs_for_group "$group" | sed 's/^/ - /'
|
||||
done
|
||||
}
|
||||
|
||||
run_attached() {
|
||||
local group
|
||||
local status=0
|
||||
local summary="$REPORT_DIR/summary.txt"
|
||||
: >"$summary"
|
||||
|
||||
for group in $(selected_groups); do
|
||||
mapfile -t specs < <(existing_specs_for_group "$group")
|
||||
if [[ "${#specs[@]}" -eq 0 ]]; then
|
||||
echo "[business-flow][SKIP] $group:无可执行 spec" | tee -a "$summary"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo ">>> [business-flow] $group" | tee -a "$summary"
|
||||
if (
|
||||
cd "$CLIENT_DIR"
|
||||
E2E_MODE=local npx playwright test "${specs[@]}" --workers=1 --reporter=list
|
||||
); then
|
||||
echo "[business-flow][PASS] $group" | tee -a "$summary"
|
||||
else
|
||||
echo "[business-flow][FAIL] $group" | tee -a "$summary"
|
||||
status=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[business-flow] report=$summary"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
help|-h|--help)
|
||||
usage
|
||||
;;
|
||||
list)
|
||||
list_groups
|
||||
;;
|
||||
attached)
|
||||
run_attached
|
||||
;;
|
||||
local)
|
||||
BUSINESS_FLOW_REPORT_DIR="$REPORT_DIR" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" business-flow-local
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#!/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="${LIVE_SMOKE_READINESS_DIR:-$ROOT_DIR/artifacts/qa-flow/live-smoke-readiness-$(date +%Y%m%d-%H%M%S)}"
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
has_live_smoke_credentials() {
|
||||
[[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
|
||||
[[ -n "${LIVE_SMOKE_ADMIN_PASSWORD:-}" ]] &&
|
||||
[[ -n "${LIVE_SMOKE_EMPLOYEE_IDENTIFIER:-}" ]] &&
|
||||
[[ -n "${LIVE_SMOKE_EMPLOYEE_PASSWORD:-}" ]]
|
||||
}
|
||||
|
||||
has_live_ocr_credentials() {
|
||||
if [[ -n "${PLAYWRIGHT_OCR_LIVE_IDENTIFIER:-}" && -n "${PLAYWRIGHT_OCR_LIVE_PASSWORD:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -n "${LIVE_SMOKE_EMPLOYEE_IDENTIFIER:-}" ]] && [[ -n "${LIVE_SMOKE_EMPLOYEE_PASSWORD:-}" ]]
|
||||
}
|
||||
|
||||
has_live_ocr_sample() {
|
||||
if [[ -n "${PLAYWRIGHT_OCR_LIVE_FILE:-}" && -f "$PLAYWRIGHT_OCR_LIVE_FILE" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${LIVE_OCR_INVOICE_FILE:-}" && -f "$LIVE_OCR_INVOICE_FILE" ]]; then
|
||||
export PLAYWRIGHT_OCR_LIVE_FILE="$LIVE_OCR_INVOICE_FILE"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${PLAYWRIGHT_OCR_LIVE_DIR:-}" && -d "$PLAYWRIGHT_OCR_LIVE_DIR" ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ">>> [live-readiness] 环境与公网入口检查"
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json > "$REPORT_DIR/env.json"
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs"
|
||||
|
||||
echo ">>> [live-readiness] /ready: $PUBLIC_BASE_URL"
|
||||
curl -fsS "$PUBLIC_BASE_URL/ready" >/dev/null
|
||||
|
||||
echo ">>> [live-readiness] 公网资源文案扫描"
|
||||
node "$ROOT_DIR/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" | tee "$REPORT_DIR/public-bundle-copy.txt"
|
||||
|
||||
if has_live_smoke_credentials; then
|
||||
echo ">>> [live-readiness] 执行公网 live smoke"
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live | tee "$REPORT_DIR/live-smoke.txt"
|
||||
else
|
||||
echo ">>> [live-readiness] SKIP live smoke:缺少完整 LIVE_SMOKE_* 凭据" | tee "$REPORT_DIR/live-smoke.txt"
|
||||
fi
|
||||
|
||||
if has_live_ocr_credentials && has_live_ocr_sample; then
|
||||
echo ">>> [live-readiness] 执行真实 OCR 样本复核"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
PLAYWRIGHT_MANUAL=1 \
|
||||
PLAYWRIGHT_OCR_LIVE=1 \
|
||||
PLAYWRIGHT_OCR_ALLOW_DUPLICATE="${PLAYWRIGHT_OCR_ALLOW_DUPLICATE:-1}" \
|
||||
E2E_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list
|
||||
) | tee "$REPORT_DIR/live-ocr.txt"
|
||||
else
|
||||
echo ">>> [live-readiness] SKIP 真实 OCR:缺少 smoke 员工凭据或 PLAYWRIGHT_OCR_LIVE_FILE/PLAYWRIGHT_OCR_LIVE_DIR" | tee "$REPORT_DIR/live-ocr.txt"
|
||||
fi
|
||||
|
||||
echo "[live-readiness][PASS] readiness 检查完成"
|
||||
echo "[live-readiness] report=$REPORT_DIR"
|
||||
@@ -10,12 +10,13 @@ mkdir -p "$ARTIFACT_ROOT"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|deploy|ci-release|r66-trialrun|help>
|
||||
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|project-wide|deploy|ci-release|r66-trialrun|help>
|
||||
|
||||
说明:
|
||||
static 运行部署契约、server Prisma 校验、client typecheck/build
|
||||
smoke-local 运行本地网页 smoke 门禁
|
||||
full-local 运行本地全量门禁(server regression + client full E2E)
|
||||
project-wide 项目级门禁:static + 覆盖扫描 + UI/移动契约 + 迁移漂移诊断 + 业务流 + 全页面视觉 + live readiness
|
||||
deploy 调用 deploy/trialrun/deploy.sh,内部会串联 /ready、external deps、runtime smoke 与 live smoke
|
||||
all 依次执行 static -> smoke-local -> full-local -> deploy
|
||||
ci-release 依次执行 static -> full-local -> deploy
|
||||
@@ -25,6 +26,8 @@ Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|de
|
||||
RELEASE_GATE_ARTIFACT_DIR 失败产物输出目录
|
||||
QA_* 透传给 scripts/qa/run-web-gates.sh
|
||||
LIVE_SMOKE_* 透传给 deploy.sh 与 live smoke
|
||||
PROJECT_WIDE_SKIP_BUSINESS_FLOW=1 跳过业务流 E2E
|
||||
PROJECT_WIDE_SKIP_VISUAL=1 跳过项目级视觉 E2E
|
||||
DEPLOY_SERVER / REMOTE_DIR / REMOTE_ENV_FILE / SYNC_ENV
|
||||
USAGE
|
||||
}
|
||||
@@ -81,6 +84,39 @@ stage_server_regression_all() {
|
||||
)
|
||||
}
|
||||
|
||||
stage_project_wide_static() {
|
||||
local issue_csv="$ROOT_DIR/issues/2026-05-08_23-38-37-project-wide-qa-second-pass.csv"
|
||||
python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py "$issue_csv"
|
||||
node "$ROOT_DIR/scripts/qa/check-ui-design-rules.mjs" --strict
|
||||
node "$ROOT_DIR/scripts/qa/check-mobile-route-contract.mjs"
|
||||
node "$ROOT_DIR/scripts/qa/check-flow-coverage.mjs" --json > "$ARTIFACT_ROOT/flow-coverage.json"
|
||||
node "$ROOT_DIR/scripts/qa/check-flow-coverage.mjs" --markdown "$ARTIFACT_ROOT/flow-coverage.md"
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json > "$ARTIFACT_ROOT/live-smoke-env.json"
|
||||
bash "$ROOT_DIR/scripts/qa/check-local-migration-drift.sh"
|
||||
}
|
||||
|
||||
stage_project_wide_business_flow() {
|
||||
if [[ "${PROJECT_WIDE_SKIP_BUSINESS_FLOW:-0}" == "1" ]]; then
|
||||
echo "[release-gate] 跳过业务流 E2E(PROJECT_WIDE_SKIP_BUSINESS_FLOW=1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
bash "$ROOT_DIR/scripts/qa/run-business-flow-gates.sh" local
|
||||
}
|
||||
|
||||
stage_project_wide_visual() {
|
||||
if [[ "${PROJECT_WIDE_SKIP_VISUAL:-0}" == "1" ]]; then
|
||||
echo "[release-gate] 跳过项目级视觉 E2E(PROJECT_WIDE_SKIP_VISUAL=1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" project-wide-visual-local
|
||||
}
|
||||
|
||||
stage_project_wide_live_readiness() {
|
||||
LIVE_SMOKE_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" bash "$ROOT_DIR/scripts/qa/run-live-smoke-readiness.sh"
|
||||
}
|
||||
|
||||
stage_deploy() {
|
||||
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
|
||||
}
|
||||
@@ -98,6 +134,13 @@ case "$MODE" in
|
||||
full-local)
|
||||
run_stage full-local stage_full_local
|
||||
;;
|
||||
project-wide)
|
||||
run_stage static stage_static
|
||||
run_stage project-wide-static stage_project_wide_static
|
||||
run_stage project-wide-business-flow stage_project_wide_business_flow
|
||||
run_stage project-wide-visual stage_project_wide_visual
|
||||
run_stage project-wide-live-readiness stage_project_wide_live_readiness
|
||||
;;
|
||||
deploy)
|
||||
run_stage deploy stage_deploy
|
||||
;;
|
||||
|
||||
+50
-1
@@ -36,7 +36,7 @@ qa_require_cmd() {
|
||||
|
||||
qa_usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-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|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
|
||||
|
||||
可选环境变量:
|
||||
QA_PORT / QA_HOST
|
||||
@@ -276,6 +276,7 @@ qa_export_app_env() {
|
||||
export GLOBAL_SEARCH_ENABLED="true"
|
||||
export ANNOUNCEMENTS_ENABLED="true"
|
||||
export PRESENCE_BOARD_ENABLED="true"
|
||||
export ATTENDANCE_ENABLED="${ATTENDANCE_ENABLED:-true}"
|
||||
export WEEKLY_REPORT_ENABLED="true"
|
||||
export EMPLOYEE_LIFECYCLE_ENABLED="true"
|
||||
export TODO_BOARD_ENABLED="true"
|
||||
@@ -285,6 +286,8 @@ qa_export_app_env() {
|
||||
export PROJECT_CLOSURE_CHECKLIST_ENABLED="true"
|
||||
export CONFIDENTIAL_DOC_REFS_ENABLED="true"
|
||||
export TRAVEL_SYNC_ENABLED="true"
|
||||
export ERP_SYNC_ENABLED="${ERP_SYNC_ENABLED:-true}"
|
||||
export PAYMENT_BATCH_ENABLED="${PAYMENT_BATCH_ENABLED:-true}"
|
||||
export IM_NOTIFIER_ENABLED="true"
|
||||
export FIELD_ENCRYPTION_KEY="$TEST_FIELD_ENCRYPTION_KEY_DEFAULT"
|
||||
}
|
||||
@@ -505,6 +508,36 @@ qa_run_r110_mobile_local() {
|
||||
)
|
||||
}
|
||||
|
||||
qa_run_business_flow_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
|
||||
|
||||
echo ">>> 执行业务流门禁"
|
||||
E2E_BASE_URL="$QA_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-business-flow-gates.sh" attached
|
||||
}
|
||||
|
||||
qa_run_project_wide_visual_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
|
||||
|
||||
echo ">>> 执行项目级全页面视觉巡检"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
rm -rf test-results playwright-report
|
||||
E2E_BASE_URL="$QA_BASE_URL" E2E_MODE=local npx playwright test tests/e2e/project-wide-visual.spec.ts --workers=1 --reporter=list
|
||||
)
|
||||
}
|
||||
|
||||
qa_run_live_smoke() {
|
||||
local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
echo ">>> 探活 live /ready: ${live_base_url}/ready"
|
||||
@@ -535,6 +568,22 @@ case "$MODE" in
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_full_local
|
||||
;;
|
||||
business-flow-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_business_flow_local
|
||||
;;
|
||||
project-wide-visual-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_project_wide_visual_local
|
||||
;;
|
||||
r70-assist-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
|
||||
在新工单中引用
屏蔽一个用户