文件
hyb-oa/scripts/qa/check-flow-coverage.mjs
T

388 行
12 KiB
JavaScript
可执行文件

#!/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);
}