#!/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 ] [--fail-on-gaps] 输出: --json 输出 JSON 到 stdout --markdown 输出 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 = /]*?)>/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("
") || "-"} |`) .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); }