388 行
12 KiB
JavaScript
可执行文件
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/项目级流程覆盖矩阵.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);
|
|
}
|