#!/usr/bin/env node import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; const defaultRootDir = path.resolve(new URL("../..", import.meta.url).pathname); const ROUTE_CLASSIFICATIONS = new Set(["core", "supported-noncore", "deferred", "frozen"]); const ROLE_FIXTURES = new Set(["employee", "manager", "ceo", "finance", "system_admin", "admin"]); function usage() { console.log(`Usage: node scripts/qa/check-flow-coverage.mjs [options] Options: --json 输出 JSON 到 stdout --markdown 输出 Markdown 摘要 --fail-on-gaps 任一本地候选阻断缺口存在时以 1 退出 --local-candidate 等同 --fail-on-gaps,并要求核心流程束均有可信 witness --root 使用指定仓库/测试 fixture 根目录 --help 显示帮助 `); } function parseArgs(argv = process.argv.slice(2)) { const parsed = { json: false, markdownPath: "", failOnGaps: false, localCandidate: false, rootDir: defaultRootDir, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--json") { parsed.json = true; } else if (arg === "--markdown") { parsed.markdownPath = argv[index + 1] ?? ""; index += 1; } else if (arg === "--fail-on-gaps") { parsed.failOnGaps = true; } else if (arg === "--local-candidate") { parsed.localCandidate = true; parsed.failOnGaps = true; } else if (arg === "--root") { parsed.rootDir = path.resolve(argv[index + 1] ?? ""); index += 1; } else if (arg === "--help" || arg === "-h") { usage(); process.exit(0); } else { throw new Error(`未知参数:${arg}`); } } if (argv.includes("--markdown") && !parsed.markdownPath) { throw new Error("--markdown 需要指定输出路径"); } if (argv.includes("--root") && !argv[argv.indexOf("--root") + 1]) { throw new Error("--root 需要指定目录"); } return parsed; } function readText(file) { return readFileSync(file, "utf8"); } function readJson(file) { try { return JSON.parse(readText(file)); } catch (error) { throw new Error(`${path.basename(file)} 不是有效 JSON:${error instanceof Error ? error.message : String(error)}`); } } function toPosix(file) { return file.split(path.sep).join("/"); } function normalizeRoute(route) { if (!route) return "/"; if (route === "*") return "*"; const prefixed = route.startsWith("/") ? route : `/${route}`; return prefixed.replace(/\/+/g, "/").replace(/\/$/, "") || "/"; } function parseRouterRoutes(routerPath) { if (!existsSync(routerPath)) { throw new Error(`路由文件不存在:${routerPath}`); } 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); routes.push(normalized); } match = routeRegex.exec(source); } return [...new Set(routes)].sort((a, b) => a.localeCompare(b)); } function routeWitness(route) { const normalized = normalizeRoute(route); if (!normalized.includes(":")) return normalized; return normalized.replace(/\/:[^/]+(?=\/|$)/g, "/").replace(/\/$/, ""); } function isManualOrLiveSpec(relativePath) { const normalized = toPosix(relativePath).toLowerCase(); return normalized.includes("/manual/") || /(^|\/|[-_.])live([-_.]|\/|$)/.test(normalized); } function inspectSpec(rootDir, relativePath, route, kind, { requireRouteWitness = true } = {}) { const normalizedRelativePath = toPosix(String(relativePath ?? "").trim()); if (!normalizedRelativePath) { return { path: normalizedRelativePath, valid: false, reason: "empty_spec_path" }; } if (isManualOrLiveSpec(normalizedRelativePath)) { return { path: normalizedRelativePath, valid: false, reason: "manual_or_live" }; } const absolutePath = path.resolve(rootDir, normalizedRelativePath); const allowedRoot = `${path.resolve(rootDir)}${path.sep}`; if (!absolutePath.startsWith(allowedRoot) || !existsSync(absolutePath) || !statSync(absolutePath).isFile()) { return { path: normalizedRelativePath, valid: false, reason: "missing_spec_file" }; } const source = readText(absolutePath); if (!/\btest(?:\.describe)?\s*\(/.test(source)) { return { path: normalizedRelativePath, valid: false, reason: "not_executable_spec" }; } const witness = routeWitness(route); if (requireRouteWitness && witness !== "/" && !source.includes(witness)) { return { path: normalizedRelativePath, valid: false, reason: "missing_route_witness" }; } if (kind === "visual" && !/(?:toHaveScreenshot|\.screenshot\s*\(|视觉)/.test(source)) { return { path: normalizedRelativePath, valid: false, reason: "not_visual_spec" }; } return { path: normalizedRelativePath, valid: true, reason: "" }; } function inspectWitnessSpec(rootDir, flow) { const base = inspectSpec(rootDir, flow?.witnessSpec, "/", "behavior", { requireRouteWitness: false }); if (!base.valid) return base; const source = readText(path.resolve(rootDir, base.path)); const manifestDrivenContract = [ "LOCAL_CANDIDATE_MANIFEST_DRIVEN_WITNESSES", "test(flow.witnessTag", "loginToWitness(page, flow.witnessRoute)", "expectUniqueH1(page, flow.expectedH1)", "installSyntheticApis(page, flow.roleFixture)", "expectPrimaryActionContract(page, flow)", "installBrowserErrorMonitor(page)", ].every((token) => source.includes(token)); const literalContract = [ flow?.witnessTag, flow?.witnessRoute, flow?.expectedH1, flow?.roleFixture, flow?.primaryAction?.allowed, flow?.primaryAction?.forbidden, ].every((token) => typeof token === "string" && token.length > 0 && source.includes(token)); if (!manifestDrivenContract && !literalContract) { return { ...base, valid: false, reason: "wrong_witness_association" }; } return base; } function validateManifestShape(manifest) { if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { throw new Error("route-manifest 顶层必须是对象"); } if (manifest.schemaVersion !== 1) { throw new Error("route-manifest.schemaVersion 必须为 1"); } if (!Array.isArray(manifest.coreBundles) || !manifest.coreBundles.length) { throw new Error("route-manifest.coreBundles 必须是非空数组"); } if (!manifest.coreBundles.every((bundle) => typeof bundle === "string" && bundle.trim())) { throw new Error("route-manifest.coreBundles 只能包含非空字符串"); } if (!Array.isArray(manifest.routes)) { throw new Error("route-manifest.routes 必须是数组"); } if (!Array.isArray(manifest.localCandidateFlows)) { throw new Error("route-manifest.localCandidateFlows 必须是数组"); } if (!Array.isArray(manifest.acceptanceSpecs) || !manifest.acceptanceSpecs.length) { throw new Error("route-manifest.acceptanceSpecs 必须是非空数组"); } } function validateRoute(route, manifestEntry, rootDir, coreBundleSet) { if (!manifestEntry) { return { path: route, classification: "unclassified", bundle: "", visualRequired: false, behaviorSpecs: [], visualSpecs: [], gaps: ["unclassified"], }; } const gaps = []; const classification = manifestEntry.classification; if (!ROUTE_CLASSIFICATIONS.has(classification)) gaps.push("invalid_classification"); const behaviorChecks = (manifestEntry.behaviorSpecs ?? []).map((spec) => inspectSpec(rootDir, spec, route, "behavior")); const visualChecks = (manifestEntry.visualSpecs ?? []).map((spec) => inspectSpec(rootDir, spec, route, "visual")); const validBehaviorSpecs = behaviorChecks.filter((item) => item.valid).map((item) => item.path); const validVisualSpecs = visualChecks.filter((item) => item.valid).map((item) => item.path); const invalidBehaviorReasons = [...new Set(behaviorChecks.filter((item) => !item.valid).map((item) => item.reason))]; const invalidVisualReasons = [...new Set(visualChecks.filter((item) => !item.valid).map((item) => item.reason))]; if (classification === "core") { if (!manifestEntry.bundle || !coreBundleSet.has(manifestEntry.bundle)) gaps.push("missing_core_bundle"); if (!validBehaviorSpecs.length) { gaps.push(invalidBehaviorReasons.includes("manual_or_live") ? "manual_only" : "missing_behavior"); } } if (manifestEntry.visualRequired && !validVisualSpecs.length) { gaps.push("missing_visual"); } return { path: route, classification, bundle: manifestEntry.bundle ?? "", visualRequired: Boolean(manifestEntry.visualRequired), behaviorSpecs: validBehaviorSpecs, visualSpecs: validVisualSpecs, evidenceProblems: { behavior: invalidBehaviorReasons, visual: invalidVisualReasons, }, gaps, }; } function validateLocalCandidateFlow(bundle, flow, context) { const gaps = []; if (!flow) { return { bundle, witnessRoute: "", witnessTag: `@local-candidate:${bundle}`, expectedH1: "", roleFixture: "", primaryAction: null, witnessSpec: "", evidenceProblems: ["missing_flow"], covered: false, gaps: ["missing_local_candidate_witness"], }; } const expectedTag = `@local-candidate:${bundle}`; if (flow.witnessTag !== expectedTag) gaps.push("invalid_witness_tag"); if (typeof flow.expectedH1 !== "string" || !flow.expectedH1.trim()) gaps.push("missing_expected_h1"); if (!ROLE_FIXTURES.has(flow.roleFixture)) gaps.push("invalid_role_fixture"); const allowedAction = flow.primaryAction?.allowed; const forbiddenAction = flow.primaryAction?.forbidden; if ( typeof allowedAction !== "string" || !allowedAction.trim() || typeof forbiddenAction !== "string" || !forbiddenAction.trim() || allowedAction === forbiddenAction ) { gaps.push("invalid_primary_action"); } const witnessRoute = normalizeRoute(flow.witnessRoute); const registered = context.registeredRouteSet.has(witnessRoute); const witnessRouteEntry = context.manifestEntries.get(witnessRoute); if (!registered || !witnessRouteEntry) { gaps.push("orphan_witness_route"); } else if (witnessRouteEntry.classification !== "core") { gaps.push("witness_route_not_core"); } else if (witnessRouteEntry.bundle !== bundle) { gaps.push("wrong_witness_association"); } const witnessCheck = inspectWitnessSpec(context.rootDir, flow); if (!witnessCheck.valid) { const reasonMap = { manual_or_live: "manual_or_live_witness", missing_spec_file: "missing_witness_spec", empty_spec_path: "missing_witness_spec", wrong_witness_association: "wrong_witness_association", }; gaps.push(reasonMap[witnessCheck.reason] ?? "invalid_witness_spec"); } return { bundle, witnessRoute, witnessTag: flow.witnessTag ?? "", expectedH1: flow.expectedH1 ?? "", roleFixture: flow.roleFixture ?? "", primaryAction: flow.primaryAction ?? null, witnessSpec: witnessCheck.valid ? witnessCheck.path : "", evidenceProblems: witnessCheck.valid ? [] : [witnessCheck.reason], covered: gaps.length === 0, gaps: [...new Set(gaps)], }; } function duplicates(values) { const seen = new Set(); const duplicateValues = new Set(); for (const value of values) { if (seen.has(value)) duplicateValues.add(value); seen.add(value); } return [...duplicateValues].sort(); } export function buildReport(rootDir) { const routerPath = path.join(rootDir, "client/src/router/index.tsx"); const manifestPath = path.join(rootDir, "scripts/qa/route-manifest.json"); if (!existsSync(manifestPath)) { throw new Error(`路由 manifest 不存在:${manifestPath}`); } const manifest = readJson(manifestPath); validateManifestShape(manifest); const registeredRoutes = parseRouterRoutes(routerPath); const manifestEntries = new Map(); const duplicateManifestRoutes = []; for (const entry of manifest.routes) { const normalizedPath = normalizeRoute(entry?.path); if (manifestEntries.has(normalizedPath)) duplicateManifestRoutes.push(normalizedPath); manifestEntries.set(normalizedPath, { ...entry, path: normalizedPath }); } const duplicateCoreBundles = duplicates(manifest.coreBundles); const coreBundleSet = new Set(manifest.coreBundles); const routes = registeredRoutes.map((route) => validateRoute(route, manifestEntries.get(route), rootDir, coreBundleSet)); const registeredRouteSet = new Set(registeredRoutes); const orphanManifestRoutes = [...manifestEntries.keys()].filter((route) => !registeredRouteSet.has(route)); const coveredBundles = new Set(routes.filter((route) => route.classification === "core" && route.behaviorSpecs.length).map((route) => route.bundle)); const missingCoreBundles = manifest.coreBundles.filter((bundle) => !coveredBundles.has(bundle)); const duplicateLocalCandidateBundles = duplicates(manifest.localCandidateFlows.map((flow) => flow?.bundle ?? "")); const duplicateWitnessTags = duplicates(manifest.localCandidateFlows.map((flow) => flow?.witnessTag ?? "")); const localCandidateFlowMap = new Map(manifest.localCandidateFlows.map((flow) => [flow?.bundle, flow])); const localCandidateFlows = manifest.coreBundles.map((bundle) => validateLocalCandidateFlow(bundle, localCandidateFlowMap.get(bundle), { rootDir, registeredRouteSet, manifestEntries, }), ); const missingLocalCandidateBundles = localCandidateFlows.filter((flow) => !flow.covered).map((flow) => flow.bundle); const unknownLocalCandidateBundles = [...localCandidateFlowMap.keys()].filter((bundle) => !coreBundleSet.has(bundle)); const duplicateAcceptanceSpecs = duplicates(manifest.acceptanceSpecs.map((spec) => toPosix(String(spec ?? "")))); const candidateSpecPaths = [...new Set([ ...manifest.localCandidateFlows.map((flow) => toPosix(String(flow?.witnessSpec ?? ""))), ...manifest.acceptanceSpecs.map((spec) => toPosix(String(spec ?? ""))), ].filter(Boolean))]; const duplicateCandidateSpecBasenames = duplicates( candidateSpecPaths.map((spec) => path.posix.basename(spec)), ); const acceptanceSpecs = manifest.acceptanceSpecs.map((spec) => { const check = inspectSpec(rootDir, spec, "/", "behavior", { requireRouteWitness: false }); return { path: check.path, valid: check.valid, reason: check.reason, gaps: check.valid ? [] : [check.reason === "manual_or_live" ? "manual_or_live_acceptance_spec" : "invalid_acceptance_spec"], }; }); const globalGaps = []; if (duplicateCoreBundles.length) globalGaps.push("duplicate_core_bundle"); if (duplicateManifestRoutes.length) globalGaps.push("duplicate_manifest_route"); if (orphanManifestRoutes.length) globalGaps.push("orphan_manifest_route"); if (missingCoreBundles.length) globalGaps.push("missing_core_bundle_behavior"); if (duplicateLocalCandidateBundles.length) globalGaps.push("duplicate_local_candidate_bundle"); if (duplicateWitnessTags.length) globalGaps.push("duplicate_witness_tag"); if (unknownLocalCandidateBundles.length) globalGaps.push("unknown_local_candidate_bundle"); if (duplicateAcceptanceSpecs.length) globalGaps.push("duplicate_acceptance_spec"); if (duplicateCandidateSpecBasenames.length) globalGaps.push("duplicate_candidate_spec_basename"); const allGapLists = [ ...routes.map((route) => route.gaps), ...localCandidateFlows.map((flow) => flow.gaps), ...acceptanceSpecs.map((spec) => spec.gaps), globalGaps, ]; const gapCounts = new Map(); for (const gaps of allGapLists) { for (const gap of gaps) gapCounts.set(gap, (gapCounts.get(gap) ?? 0) + 1); } const classificationCounts = Object.fromEntries( [...ROUTE_CLASSIFICATIONS].map((classification) => [classification, routes.filter((route) => route.classification === classification).length]), ); const blockingGapCount = allGapLists.reduce((count, gaps) => count + gaps.length, 0); return { generatedAt: new Date().toISOString(), manifest: toPosix(path.relative(rootDir, manifestPath)), summary: { routeCount: routes.length, classificationCounts, coreBundleCount: manifest.coreBundles.length, coveredCoreBundleCount: coveredBundles.size, coveredWitnessCount: localCandidateFlows.filter((flow) => flow.covered).length, acceptanceSpecCount: acceptanceSpecs.length, validAcceptanceSpecCount: acceptanceSpecs.filter((spec) => spec.valid).length, blockingGapCount, gapCounts: Object.fromEntries([...gapCounts.entries()].sort()), }, coreBundles: manifest.coreBundles.map((bundle) => ({ bundle, covered: coveredBundles.has(bundle) })), localCandidateFlows, acceptanceSpecs, duplicateCoreBundles, duplicateManifestRoutes, duplicateLocalCandidateBundles, duplicateWitnessTags, duplicateAcceptanceSpecs, duplicateCandidateSpecBasenames, orphanManifestRoutes, missingCoreBundles, missingLocalCandidateBundles, unknownLocalCandidateBundles, globalGaps, routes, }; } function renderMarkdown(report) { const gapSummary = Object.entries(report.summary.gapCounts) .map(([gap, count]) => `- ${gap}: ${count}`) .join("\n") || "- none: 0"; const bundleRows = report.localCandidateFlows .map((item) => `| ${item.bundle} | ${item.witnessTag} | ${item.witnessRoute} | ${item.roleFixture} | ${item.covered ? "Y" : "N"} |`) .join("\n"); const routeRows = report.routes .map((route) => `| ${route.path} | ${route.classification} | ${route.bundle || "-"} | ${route.behaviorSpecs.length} | ${route.visualSpecs.length} | ${route.gaps.join("
") || "-"} |`) .join("\n"); return `# 本地候选路由与流程覆盖扫描 - 生成时间:${report.generatedAt} - 注册路由数:${report.summary.routeCount} - 核心流程束:${report.summary.coveredCoreBundleCount}/${report.summary.coreBundleCount} - 可信 witness:${report.summary.coveredWitnessCount}/${report.summary.coreBundleCount} - 验收 specs:${report.summary.validAcceptanceSpecCount}/${report.summary.acceptanceSpecCount} - 阻断缺口数:${report.summary.blockingGapCount} ## 缺口汇总 ${gapSummary} ## 核心流程束 witness | Bundle | Tag | Route | Role fixture | Covered | | --- | --- | --- | --- | --- | ${bundleRows} ## 路由覆盖 | Route | Classification | Bundle | Behavior | Visual | Gaps | | --- | --- | --- | ---: | ---: | --- | ${routeRows} `; } export function hasBlockingGaps(report) { return report.summary.blockingGapCount > 0; } function main() { const args = parseArgs(); const report = buildReport(args.rootDir); if (args.markdownPath) { const outputPath = path.resolve(args.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 && hasBlockingGaps(report)) { process.exitCode = 1; } } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { try { main(); } catch (error) { console.error(`[flow-coverage][ERROR] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } }