完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)
这个提交包含在:
+25
-8
@@ -2,12 +2,25 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MODE="${1:-default}"
|
||||
|
||||
case "$MODE" in
|
||||
default|local-candidate) ;;
|
||||
*)
|
||||
echo "Usage: bash scripts/check-minimal.sh [local-candidate]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "=== [1/7] Process Contract Gate ==="
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
node scripts/qa/check-process-contract.mjs --baseline
|
||||
)
|
||||
if [[ "$MODE" == "local-candidate" ]]; then
|
||||
echo "[SKIP] local-candidate 不读取历史 Issue CSV 流程基线"
|
||||
else
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
node scripts/qa/check-process-contract.mjs --baseline
|
||||
)
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== [2/7] Governance + Sensitive Content ==="
|
||||
@@ -95,10 +108,14 @@ echo "=== [6/7] Client Typecheck + Build ==="
|
||||
|
||||
echo ""
|
||||
echo "=== [7/7] Feature Matrix Consistency ==="
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
python3 scripts/validate-feature-matrix.py
|
||||
)
|
||||
if [[ "$MODE" == "local-candidate" ]]; then
|
||||
echo "[SKIP] local-candidate 不读取历史 Issue CSV 功能矩阵"
|
||||
else
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
python3 scripts/validate-feature-matrix.py
|
||||
)
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[OK] Minimal checks passed"
|
||||
|
||||
可执行文件 -> 普通文件
+386
-251
@@ -1,41 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
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");
|
||||
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 [--json] [--markdown <path>] [--fail-on-gaps]
|
||||
console.log(`Usage: node scripts/qa/check-flow-coverage.mjs [options]
|
||||
|
||||
输出:
|
||||
--json 输出 JSON 到 stdout
|
||||
--markdown <path> 输出 Markdown 摘要到指定路径
|
||||
--fail-on-gaps 存在 P0 missing_e2e 或 missing_visual 时以 1 退出
|
||||
Options:
|
||||
--json 输出 JSON 到 stdout
|
||||
--markdown <path> 输出 Markdown 摘要
|
||||
--fail-on-gaps 任一本地候选阻断缺口存在时以 1 退出
|
||||
--local-candidate 等同 --fail-on-gaps,并要求核心流程束均有可信 witness
|
||||
--root <path> 使用指定仓库/测试 fixture 根目录
|
||||
--help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const parsed = {
|
||||
json: false,
|
||||
markdownPath: "",
|
||||
failOnGaps: false,
|
||||
localCandidate: false,
|
||||
rootDir: defaultRootDir,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
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 = args[index + 1] ?? "";
|
||||
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);
|
||||
@@ -44,9 +52,12 @@ function parseArgs() {
|
||||
}
|
||||
}
|
||||
|
||||
if (args.includes("--markdown") && !parsed.markdownPath) {
|
||||
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;
|
||||
}
|
||||
@@ -55,64 +66,18 @@ 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);
|
||||
}
|
||||
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)}`);
|
||||
}
|
||||
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 "*";
|
||||
@@ -120,24 +85,11 @@ function normalizeRoute(route) {
|
||||
return prefixed.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
||||
}
|
||||
|
||||
function routeToKeyword(route) {
|
||||
return normalizeRoute(route)
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/:[^/]+/g, "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
function parseRouterRoutes(routerPath) {
|
||||
if (!existsSync(routerPath)) {
|
||||
throw new Error(`路由文件不存在:${routerPath}`);
|
||||
}
|
||||
|
||||
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 = [];
|
||||
@@ -149,159 +101,352 @@ function parseRouterRoutes() {
|
||||
if (pathMatch || indexRoute) {
|
||||
const rawPath = pathMatch?.[1] ?? pathMatch?.[2] ?? "/";
|
||||
const normalized = indexRoute && !pathMatch ? "/" : normalizeRoute(rawPath);
|
||||
if (normalized !== "*" && normalized !== "/403") {
|
||||
routes.push({ path: normalized });
|
||||
}
|
||||
routes.push(normalized);
|
||||
}
|
||||
match = routeRegex.exec(source);
|
||||
}
|
||||
|
||||
return [...new Map(routes.map((route) => [route.path, route])).values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
return [...new Set(routes)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
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) {
|
||||
function routeWitness(route) {
|
||||
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));
|
||||
if (!normalized.includes(":")) return normalized;
|
||||
return normalized.replace(/\/:[^/]+(?=\/|$)/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
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 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");
|
||||
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.path,
|
||||
title: titleMeta?.title ?? "",
|
||||
group: titleMeta?.group ?? "",
|
||||
domain: domain?.Domain ?? "未映射",
|
||||
tier: domain?.Tier ?? "unmapped",
|
||||
coverage,
|
||||
specs: matchedSpecs.map((spec) => spec.path),
|
||||
path: route,
|
||||
classification,
|
||||
bundle: manifestEntry.bundle ?? "",
|
||||
visualRequired: Boolean(manifestEntry.visualRequired),
|
||||
behaviorSpecs: validBehaviorSpecs,
|
||||
visualSpecs: validVisualSpecs,
|
||||
evidenceProblems: {
|
||||
behavior: invalidBehaviorReasons,
|
||||
visual: invalidVisualReasons,
|
||||
},
|
||||
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);
|
||||
}
|
||||
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 domainRows = domains.map((domain) => {
|
||||
const domainRoutes = routes.filter((route) => route.domain === domain.Domain);
|
||||
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 {
|
||||
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,
|
||||
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,
|
||||
domainCount: domains.length,
|
||||
serverRouteCount: serverRoutes.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()),
|
||||
highRiskGapCount: routes.filter((route) => route.tier === "P0" && route.gaps.length).length,
|
||||
},
|
||||
domains: domainRows,
|
||||
coreBundles: manifest.coreBundles.map((bundle) => ({ bundle, covered: coveredBundles.has(bundle) })),
|
||||
localCandidateFlows,
|
||||
acceptanceSpecs,
|
||||
duplicateCoreBundles,
|
||||
duplicateManifestRoutes,
|
||||
duplicateLocalCandidateBundles,
|
||||
duplicateWitnessTags,
|
||||
duplicateAcceptanceSpecs,
|
||||
duplicateCandidateSpecBasenames,
|
||||
orphanManifestRoutes,
|
||||
missingCoreBundles,
|
||||
missingLocalCandidateBundles,
|
||||
unknownLocalCandidateBundles,
|
||||
globalGaps,
|
||||
routes,
|
||||
serverRoutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,57 +454,50 @@ 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.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>") || "-"} |`)
|
||||
.map((route) => `| ${route.path} | ${route.classification} | ${route.bundle || "-"} | ${route.behaviorSpecs.length} | ${route.visualSpecs.length} | ${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 `# 项目级流程覆盖扫描
|
||||
return `# 本地候选路由与流程覆盖扫描
|
||||
|
||||
- 生成时间:${report.generatedAt}
|
||||
- 前端路由数:${report.summary.routeCount}
|
||||
- 业务域数:${report.summary.domainCount}
|
||||
- 后端 route 文件数:${report.summary.serverRouteCount}
|
||||
- P0 含缺口路由数:${report.summary.highRiskGapCount}
|
||||
- 注册路由数:${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
|
||||
|
||||
| Domain | Tier | Routes | Gaps | Next Action |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
${domainRows}
|
||||
| Bundle | Tag | Route | Role fixture | Covered |
|
||||
| --- | --- | --- | --- | --- |
|
||||
${bundleRows}
|
||||
|
||||
## 路由覆盖
|
||||
|
||||
| Route | Domain | Tier | PC | Mobile | WeCom | Visual | Flow | Gaps |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| Route | Classification | Bundle | Behavior | Visual | Gaps |
|
||||
| --- | --- | --- | ---: | ---: | --- |
|
||||
${routeRows}
|
||||
`;
|
||||
}
|
||||
|
||||
export function hasBlockingGaps(report) {
|
||||
return report.summary.blockingGapCount > 0;
|
||||
}
|
||||
|
||||
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);
|
||||
const report = buildReport(args.rootDir);
|
||||
|
||||
if (args.markdownPath) {
|
||||
const outputPath = path.resolve(rootDir, 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}`);
|
||||
@@ -369,19 +507,16 @@ function main() {
|
||||
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);
|
||||
}
|
||||
if (args.failOnGaps && hasBlockingGaps(report)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`[flow-coverage][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"coreBundles": [
|
||||
"login-logout",
|
||||
"reimbursement",
|
||||
"approval",
|
||||
"invoice",
|
||||
"procurement",
|
||||
"project-contract",
|
||||
"payment-ap",
|
||||
"people-audit",
|
||||
"attendance-archive",
|
||||
"mobile-workspace"
|
||||
],
|
||||
"acceptanceSpecs": [
|
||||
"client/tests/e2e/r102-reimbursement-main-flow.spec.ts",
|
||||
"client/tests/e2e/r103-cross-page-ui-consistency.spec.ts",
|
||||
"client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts",
|
||||
"client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts",
|
||||
"client/tests/e2e/internal-readiness-ui.spec.ts",
|
||||
"client/tests/e2e/system-action-confirmation.spec.ts",
|
||||
"client/tests/e2e/payment-reviewer-permissions.spec.ts"
|
||||
],
|
||||
"localCandidateFlows": [
|
||||
{
|
||||
"bundle": "login-logout",
|
||||
"witnessRoute": "/login",
|
||||
"witnessTag": "@local-candidate:login-logout",
|
||||
"expectedH1": "海音达 OA",
|
||||
"roleFixture": "employee",
|
||||
"primaryAction": { "allowed": "登录", "forbidden": "应急超级管理员" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "reimbursement",
|
||||
"witnessRoute": "/reimbursements",
|
||||
"witnessTag": "@local-candidate:reimbursement",
|
||||
"expectedH1": "我的报销",
|
||||
"roleFixture": "employee",
|
||||
"primaryAction": { "allowed": "新建报销", "forbidden": "导出" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "approval",
|
||||
"witnessRoute": "/workflow/tasks",
|
||||
"witnessTag": "@local-candidate:approval",
|
||||
"expectedH1": "待我处理",
|
||||
"roleFixture": "manager",
|
||||
"primaryAction": { "allowed": "处理", "forbidden": "通过" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "invoice",
|
||||
"witnessRoute": "/invoice-pool",
|
||||
"witnessTag": "@local-candidate:invoice",
|
||||
"expectedH1": "票据",
|
||||
"roleFixture": "employee",
|
||||
"primaryAction": { "allowed": "上传票据", "forbidden": "作废" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "procurement",
|
||||
"witnessRoute": "/purchase-requests",
|
||||
"witnessTag": "@local-candidate:procurement",
|
||||
"expectedH1": "采购",
|
||||
"roleFixture": "employee",
|
||||
"primaryAction": { "allowed": "新建采购", "forbidden": "通过" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "project-contract",
|
||||
"witnessRoute": "/contracts-registry",
|
||||
"witnessTag": "@local-candidate:project-contract",
|
||||
"expectedH1": "合同",
|
||||
"roleFixture": "manager",
|
||||
"primaryAction": { "allowed": "新增合同", "forbidden": "执行付款" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "payment-ap",
|
||||
"witnessRoute": "/payment-batches",
|
||||
"witnessTag": "@local-candidate:payment-ap",
|
||||
"expectedH1": "付款",
|
||||
"roleFixture": "finance",
|
||||
"primaryAction": { "allowed": "新建批次", "forbidden": "复核批次" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "people-audit",
|
||||
"witnessRoute": "/system/users",
|
||||
"witnessTag": "@local-candidate:people-audit",
|
||||
"expectedH1": "系统用户管理",
|
||||
"roleFixture": "system_admin",
|
||||
"primaryAction": { "allowed": "新建用户", "forbidden": "应急超级管理员" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "attendance-archive",
|
||||
"witnessRoute": "/mobile/attendance",
|
||||
"witnessTag": "@local-candidate:attendance-archive",
|
||||
"expectedH1": "考勤",
|
||||
"roleFixture": "employee",
|
||||
"primaryAction": { "allowed": "修正申请", "forbidden": "提交应急修正" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
},
|
||||
{
|
||||
"bundle": "mobile-workspace",
|
||||
"witnessRoute": "/mobile",
|
||||
"witnessTag": "@local-candidate:mobile-workspace",
|
||||
"expectedH1": "今天待办",
|
||||
"roleFixture": "employee",
|
||||
"primaryAction": { "allowed": "新建报销", "forbidden": "财务" },
|
||||
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{ "path": "*", "classification": "supported-noncore" },
|
||||
{ "path": "/", "classification": "supported-noncore" },
|
||||
{ "path": "/403", "classification": "supported-noncore" },
|
||||
{ "path": "/announcements", "classification": "supported-noncore" },
|
||||
{ "path": "/ap-operations", "classification": "core", "bundle": "payment-ap", "behaviorSpecs": ["client/tests/e2e/reimbursement-payment-batch-flow.spec.ts"] },
|
||||
{ "path": "/assets", "classification": "supported-noncore", "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/attendance", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/r75-automation-gap-fill.spec.ts"] },
|
||||
{ "path": "/audit-logs", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/settings-governance.spec.ts"] },
|
||||
{ "path": "/budgets", "classification": "supported-noncore" },
|
||||
{ "path": "/change-password", "classification": "supported-noncore" },
|
||||
{ "path": "/contracts-registry", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
|
||||
{ "path": "/dashboard", "classification": "deferred" },
|
||||
{ "path": "/delegations", "classification": "supported-noncore" },
|
||||
{ "path": "/forgot-password", "classification": "supported-noncore" },
|
||||
{ "path": "/inventory-checks", "classification": "supported-noncore" },
|
||||
{ "path": "/invoice-pool", "classification": "core", "bundle": "invoice", "behaviorSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"] },
|
||||
{ "path": "/leave-requests", "classification": "supported-noncore" },
|
||||
{ "path": "/login", "classification": "core", "bundle": "login-logout", "behaviorSpecs": ["client/tests/e2e/login-portal-ux.spec.ts"] },
|
||||
{ "path": "/mobile", "classification": "core", "bundle": "mobile-workspace", "behaviorSpecs": ["client/tests/e2e/r124-mobile-entry-matrix.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r108-mobile-wecom-visual.spec.ts"] },
|
||||
{ "path": "/mobile/approvals", "classification": "core", "bundle": "approval", "behaviorSpecs": ["client/tests/e2e/mobile-approvals-offline.spec.ts"] },
|
||||
{ "path": "/mobile/approvals/:taskId", "classification": "core", "bundle": "approval", "behaviorSpecs": ["client/tests/e2e/mobile-approvals-offline.spec.ts"] },
|
||||
{ "path": "/mobile/assets/scan", "classification": "supported-noncore" },
|
||||
{ "path": "/mobile/attendance", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"] },
|
||||
{ "path": "/mobile/capture", "classification": "core", "bundle": "invoice", "behaviorSpecs": ["client/tests/e2e/r124-mobile-entry-matrix.spec.ts"] },
|
||||
{ "path": "/mobile/leave", "classification": "supported-noncore" },
|
||||
{ "path": "/mobile/leave/:leaveRequestId", "classification": "supported-noncore" },
|
||||
{ "path": "/mobile/me", "classification": "core", "bundle": "mobile-workspace", "behaviorSpecs": ["client/tests/e2e/r124-mobile-entry-matrix.spec.ts"] },
|
||||
{ "path": "/mobile/notifications", "classification": "core", "bundle": "mobile-workspace", "behaviorSpecs": ["client/tests/e2e/dg7-mobile-information-dedupe.spec.ts"] },
|
||||
{ "path": "/mobile/profile", "classification": "supported-noncore" },
|
||||
{ "path": "/mobile/purchase-requests", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
|
||||
{ "path": "/mobile/purchase-requests/:purchaseRequestId", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
|
||||
{ "path": "/mobile/reimbursements", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"] },
|
||||
{ "path": "/mobile/reimbursements/:reimbursementId", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"] },
|
||||
{ "path": "/mobile/wecom-approval-archive", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"] },
|
||||
{ "path": "/notifications", "classification": "supported-noncore" },
|
||||
{ "path": "/organization", "classification": "supported-noncore" },
|
||||
{ "path": "/payment-batches", "classification": "core", "bundle": "payment-ap", "behaviorSpecs": ["client/tests/e2e/reimbursement-payment-batch-flow.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"] },
|
||||
{ "path": "/payroll", "classification": "supported-noncore" },
|
||||
{ "path": "/people", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/r75-automation-gap-fill.spec.ts"] },
|
||||
{ "path": "/people/:employeeId", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/r75-automation-gap-fill.spec.ts"] },
|
||||
{ "path": "/people/stats", "classification": "supported-noncore" },
|
||||
{ "path": "/performance", "classification": "supported-noncore" },
|
||||
{ "path": "/portal", "classification": "core", "bundle": "login-logout", "behaviorSpecs": ["client/tests/e2e/login-portal-ux.spec.ts"] },
|
||||
{ "path": "/projects", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/project-workbench.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/projects/:projectId", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/project-workbench.spec.ts"] },
|
||||
{ "path": "/purchase-requests", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/procurement-flow.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/reimbursements", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/reimbursements/:reimbursementId", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"] },
|
||||
{ "path": "/reimbursements/:reimbursementId/print", "classification": "supported-noncore" },
|
||||
{ "path": "/reimbursements/dashboard", "classification": "supported-noncore" },
|
||||
{ "path": "/settings/profile", "classification": "supported-noncore" },
|
||||
{ "path": "/system/announcements", "classification": "supported-noncore" },
|
||||
{ "path": "/system/approval-rules", "classification": "supported-noncore" },
|
||||
{ "path": "/system/cutover", "classification": "frozen" },
|
||||
{ "path": "/system/departments", "classification": "supported-noncore" },
|
||||
{ "path": "/system/expense-standards", "classification": "supported-noncore" },
|
||||
{ "path": "/system/feature-flags", "classification": "supported-noncore" },
|
||||
{ "path": "/system/master-data", "classification": "supported-noncore" },
|
||||
{ "path": "/system/notification-channels", "classification": "supported-noncore" },
|
||||
{ "path": "/system/reimbursement-templates", "classification": "supported-noncore" },
|
||||
{ "path": "/system/settings", "classification": "supported-noncore" },
|
||||
{ "path": "/system/users", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts"] },
|
||||
{ "path": "/system/workflow-definitions", "classification": "supported-noncore" },
|
||||
{ "path": "/system/workspace", "classification": "supported-noncore" },
|
||||
{ "path": "/team", "classification": "supported-noncore" },
|
||||
{ "path": "/todos", "classification": "supported-noncore" },
|
||||
{ "path": "/travel-requests", "classification": "frozen" },
|
||||
{ "path": "/wecom-approval-archive", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/internal-readiness-ui.spec.ts"] },
|
||||
{ "path": "/weekly-reports", "classification": "supported-noncore" },
|
||||
{ "path": "/workflow/tasks", "classification": "core", "bundle": "approval", "behaviorSpecs": ["client/tests/e2e/workflow-action-confirm-ui.spec.ts"] },
|
||||
{ "path": "/workflow/tasks/:taskId", "classification": "core", "bundle": "approval", "behaviorSpecs": ["client/tests/e2e/workflow-action-confirm-ui.spec.ts"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
MODE="${1:-full}"
|
||||
DEV_SERVER_PID=""
|
||||
DEV_SERVER_LOG=""
|
||||
PLAYWRIGHT_RESULT_FILE=""
|
||||
PLAYWRIGHT_STDERR_LOG=""
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
"Usage: bash scripts/qa/run-local-candidate.sh [full|focused|list]" \
|
||||
"" \
|
||||
" full 完整基础门禁 + 路由反例 + 本地 Vite 行为测试(默认)" \
|
||||
" focused 路由反例 + 本地 Vite 行为测试,不代表完整候选通过" \
|
||||
" list 只解析 Playwright 测试资产,不代表行为通过"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local exit_code="$?"
|
||||
if [[ -n "$DEV_SERVER_PID" ]] && kill -0 "$DEV_SERVER_PID" 2>/dev/null; then
|
||||
kill "$DEV_SERVER_PID" 2>/dev/null || true
|
||||
wait "$DEV_SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ "$exit_code" -ne 0 ]]; then
|
||||
local vite_log_lines=0
|
||||
local playwright_log_lines=0
|
||||
if [[ -n "$DEV_SERVER_LOG" && -f "$DEV_SERVER_LOG" ]]; then
|
||||
vite_log_lines="$(wc -l < "$DEV_SERVER_LOG" | tr -d ' ')"
|
||||
fi
|
||||
if [[ -n "$PLAYWRIGHT_STDERR_LOG" && -f "$PLAYWRIGHT_STDERR_LOG" ]]; then
|
||||
playwright_log_lines="$(wc -l < "$PLAYWRIGHT_STDERR_LOG" | tr -d ' ')"
|
||||
fi
|
||||
echo "[local-candidate][ERROR] safe_summary exit=${exit_code} vite_log_lines=${vite_log_lines} playwright_log_lines=${playwright_log_lines}" >&2
|
||||
fi
|
||||
[[ -z "$DEV_SERVER_LOG" ]] || rm -f "$DEV_SERVER_LOG"
|
||||
[[ -z "$PLAYWRIGHT_RESULT_FILE" ]] || rm -f "$PLAYWRIGHT_RESULT_FILE"
|
||||
[[ -z "$PLAYWRIGHT_STDERR_LOG" ]] || rm -f "$PLAYWRIGHT_STDERR_LOG"
|
||||
return "$exit_code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
case "$MODE" in
|
||||
full|focused|list) ;;
|
||||
help|-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "=== [1/4] 路由 manifest 与反例测试 ==="
|
||||
node --test \
|
||||
scripts/qa/tests/check-minimal-profile.test.mjs \
|
||||
scripts/qa/tests/check-flow-coverage.test.mjs \
|
||||
scripts/qa/tests/validate-loopback-url.test.mjs \
|
||||
scripts/qa/tests/verify-local-candidate-results.test.mjs
|
||||
node scripts/qa/check-flow-coverage.mjs --local-candidate --json
|
||||
|
||||
if [[ "$MODE" == "full" ]]; then
|
||||
echo "=== [2/4] 完整本地基础门禁 ==="
|
||||
bash scripts/check-minimal.sh local-candidate
|
||||
else
|
||||
echo "=== [2/4] 完整本地基础门禁(focused/list 跳过) ==="
|
||||
fi
|
||||
|
||||
mapfile -t CANDIDATE_SPECS < <(
|
||||
node -e '
|
||||
const manifest = require("./scripts/qa/route-manifest.json");
|
||||
const expected = new Set(manifest.coreBundles ?? []);
|
||||
const flows = manifest.localCandidateFlows ?? [];
|
||||
const observed = new Set(flows.map((flow) => flow.bundle));
|
||||
if (expected.size !== 10 || expected.size !== observed.size || [...expected].some((bundle) => !observed.has(bundle))) {
|
||||
throw new Error("localCandidateFlows 未完整覆盖 coreBundles");
|
||||
}
|
||||
const witnessSpecs = flows.map((flow) => flow.witnessSpec);
|
||||
const specs = [...new Set([...witnessSpecs, ...(manifest.acceptanceSpecs ?? [])])];
|
||||
for (const spec of specs) {
|
||||
if (typeof spec !== "string" || !spec.startsWith("client/tests/e2e/") || spec.includes("/manual/") || /live/i.test(spec)) {
|
||||
throw new Error(`非法本地候选 spec: ${String(spec)}`);
|
||||
}
|
||||
console.log(spec.slice("client/".length));
|
||||
}
|
||||
'
|
||||
)
|
||||
|
||||
if [[ "${#CANDIDATE_SPECS[@]}" -eq 0 ]]; then
|
||||
echo "[local-candidate][ERROR] route-manifest 未登记 localCandidateFlows" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "list" ]]; then
|
||||
echo "=== [3/4] Playwright 资产解析(不代表行为通过) ==="
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
npx playwright test "${CANDIDATE_SPECS[@]}" --list
|
||||
)
|
||||
echo "=== [4/4] LIST_ONLY ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BASE_URL="${E2E_BASE_URL:-}"
|
||||
if [[ -z "$BASE_URL" ]]; then
|
||||
PORT="$(node scripts/qa/validate-loopback-url.mjs --port "${LOCAL_CANDIDATE_PORT:-5178}")"
|
||||
BASE_URL="$(node scripts/qa/validate-loopback-url.mjs "http://127.0.0.1:${PORT}")"
|
||||
DEV_SERVER_LOG="${TMPDIR:-/tmp}/hyb-oa-local-candidate-vite-${PORT}-$$.log"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
npm run dev -- --host 127.0.0.1 --port "$PORT" --strictPort
|
||||
) >"$DEV_SERVER_LOG" 2>&1 &
|
||||
DEV_SERVER_PID="$!"
|
||||
|
||||
VITE_READY=0
|
||||
for _ in $(seq 1 60); do
|
||||
if curl --fail --silent --show-error "$BASE_URL/login" >/dev/null 2>&1; then
|
||||
VITE_READY=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$DEV_SERVER_PID" 2>/dev/null; then
|
||||
echo "[local-candidate][ERROR] 本地 Vite 启动失败" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
if [[ "$VITE_READY" -ne 1 ]]; then
|
||||
echo "[local-candidate][ERROR] 本地 Vite 就绪超时" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
BASE_URL="$(node scripts/qa/validate-loopback-url.mjs "$BASE_URL")"
|
||||
fi
|
||||
|
||||
echo "=== [3/4] 本地合成浏览器行为 ==="
|
||||
PLAYWRIGHT_RESULT_FILE="$(mktemp "${TMPDIR:-/tmp}/hyb-oa-local-candidate-results.XXXXXX.json")"
|
||||
PLAYWRIGHT_STDERR_LOG="$(mktemp "${TMPDIR:-/tmp}/hyb-oa-local-candidate-playwright.XXXXXX.log")"
|
||||
|
||||
PLAYWRIGHT_STATUS=0
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
E2E_MODE=local E2E_BASE_URL="$BASE_URL" \
|
||||
npx playwright test "${CANDIDATE_SPECS[@]}" --workers=1 --reporter=json
|
||||
) >"$PLAYWRIGHT_RESULT_FILE" 2>"$PLAYWRIGHT_STDERR_LOG" || PLAYWRIGHT_STATUS="$?"
|
||||
|
||||
RESULT_GATE_STATUS=0
|
||||
node scripts/qa/verify-local-candidate-results.mjs \
|
||||
--manifest scripts/qa/route-manifest.json \
|
||||
--results "$PLAYWRIGHT_RESULT_FILE" || RESULT_GATE_STATUS="$?"
|
||||
|
||||
if [[ "$PLAYWRIGHT_STATUS" -ne 0 || "$RESULT_GATE_STATUS" -ne 0 ]]; then
|
||||
echo "[local-candidate][ERROR] browser_exit=${PLAYWRIGHT_STATUS} witness_result_gate=${RESULT_GATE_STATUS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "full" ]]; then
|
||||
echo "=== [4/4] LOCAL_CANDIDATE_PASS ==="
|
||||
else
|
||||
echo "=== [4/4] FOCUSED_BROWSER_PASS ==="
|
||||
fi
|
||||
+66
-7
@@ -19,6 +19,8 @@ QA_DB_DOCKER_CONTAINER="${QA_DB_DOCKER_CONTAINER:-hyb-oa-trialrun-db}"
|
||||
QA_DOCKER_USE_SG="${QA_DOCKER_USE_SG:-0}"
|
||||
if [[ "$MODE" == "auth-security-local" ]]; then
|
||||
DEFAULT_QA_DB_PREFIX="hyb_oa_auth"
|
||||
elif [[ "$MODE" == "payment-concurrency-local" ]]; then
|
||||
DEFAULT_QA_DB_PREFIX="hyb_oa_payment"
|
||||
else
|
||||
DEFAULT_QA_DB_PREFIX="hyb_oa_webgate"
|
||||
fi
|
||||
@@ -46,7 +48,7 @@ qa_require_cmd() {
|
||||
|
||||
qa_usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|auth-security-local|business-flow-local|project-wide-visual-local|qg6-visual-matrix-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|auth-security-local|payment-concurrency-local|business-flow-local|project-wide-visual-local|qg6-visual-matrix-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
|
||||
|
||||
可选环境变量:
|
||||
QA_PORT / QA_HOST
|
||||
@@ -223,13 +225,9 @@ qa_dropdb() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! qa_psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null; then
|
||||
echo "[ERROR] 临时数据库连接清理失败" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$QA_DB_CLIENT" == "docker" ]]; then
|
||||
if ! qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \
|
||||
dropdb --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then
|
||||
dropdb --force --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then
|
||||
echo "[ERROR] 临时数据库删除失败" >&2
|
||||
return 1
|
||||
fi
|
||||
@@ -237,7 +235,7 @@ qa_dropdb() {
|
||||
return
|
||||
fi
|
||||
|
||||
if ! PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then
|
||||
if ! PGPASSWORD="$QA_DB_PASSWORD" dropdb --force --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then
|
||||
echo "[ERROR] 临时数据库删除失败" >&2
|
||||
return 1
|
||||
fi
|
||||
@@ -391,6 +389,59 @@ qa_run_auth_security_local() {
|
||||
)
|
||||
}
|
||||
|
||||
qa_run_payment_concurrency_local() {
|
||||
case "$QA_DB_HOST" in
|
||||
127.0.0.1|localhost|::1) ;;
|
||||
*)
|
||||
echo "[ERROR] payment-concurrency-local 只允许 loopback PostgreSQL" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [[ ! "$QA_DB_NAME" =~ ^hyb_oa_payment_[A-Za-z0-9_]+$ ]]; then
|
||||
echo "[ERROR] payment-concurrency-local 数据库名必须使用 hyb_oa_payment_ 前缀" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
qa_select_port
|
||||
export JOBS_ENABLED=false
|
||||
export RATE_LIMIT_ENABLED=false
|
||||
export TRUST_PROXY_HOPS=0
|
||||
export PAYMENT_BATCH_ENABLED=true
|
||||
export ERP_SYNC_ENABLED=false
|
||||
export SERVE_CLIENT_DIST=false
|
||||
unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY
|
||||
qa_export_app_env
|
||||
export SERVE_CLIENT_DIST=false
|
||||
export METRICS_ENABLED=false
|
||||
export EMAIL_ENABLED=false
|
||||
export SMTP_HOST=""
|
||||
export SMTP_PORT=""
|
||||
export SMTP_SECURE=false
|
||||
export SMTP_USER=""
|
||||
export SMTP_PASS=""
|
||||
export IM_NOTIFIER_ENABLED=false
|
||||
export WECOM_ENABLED=false
|
||||
export WECOM_AGENT_SECRET=""
|
||||
export WECOM_CONTACT_SECRET=""
|
||||
export WECOM_HR_ASSISTANT_SECRET=""
|
||||
export OIDC_ENABLED=false
|
||||
export OIDC_CLIENT_SECRET=""
|
||||
export STORAGE_DRIVER=local
|
||||
JWT_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')"
|
||||
AUTH_CODE_PEPPER="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')"
|
||||
FIELD_ENCRYPTION_KEY="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64"))')"
|
||||
export JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY
|
||||
export PAYMENT_CONCURRENCY_DATABASE_CONFIRM="$QA_DB_NAME"
|
||||
|
||||
qa_prepare_db
|
||||
|
||||
echo ">>> 执行付款真实 PostgreSQL 并发回归"
|
||||
(
|
||||
cd "$SERVER_DIR"
|
||||
RUN_PAYMENT_POSTGRES_CONCURRENCY=1 node src/scripts/check-payment-postgres-concurrency.js
|
||||
)
|
||||
}
|
||||
|
||||
qa_prepare_db() {
|
||||
echo ">>> 生成并校验 Prisma Client"
|
||||
(
|
||||
@@ -725,6 +776,14 @@ case "$MODE" in
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_auth_security_local
|
||||
;;
|
||||
payment-concurrency-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
trap qa_cleanup EXIT
|
||||
qa_run_payment_concurrency_local
|
||||
;;
|
||||
business-flow-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_db_client
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { buildReport, hasBlockingGaps } from "../check-flow-coverage.mjs";
|
||||
|
||||
const WITNESS_SPEC = `
|
||||
import { test } from "@playwright/test";
|
||||
export const LOCAL_CANDIDATE_MANIFEST_DRIVEN_WITNESSES = true;
|
||||
for (const flow of localCandidateFlows) {
|
||||
test(flow.witnessTag, async ({ page }) => {
|
||||
installBrowserErrorMonitor(page);
|
||||
installSyntheticApis(page, flow.roleFixture);
|
||||
await loginToWitness(page, flow.witnessRoute);
|
||||
await expectUniqueH1(page, flow.expectedH1);
|
||||
await expectPrimaryActionContract(page, flow);
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
function candidateFlow(bundle = "core-flow", witnessRoute = "/core", overrides = {}) {
|
||||
return {
|
||||
bundle,
|
||||
witnessRoute,
|
||||
witnessTag: `@local-candidate:${bundle}`,
|
||||
expectedH1: "核心页面",
|
||||
roleFixture: "employee",
|
||||
primaryAction: { allowed: "开始", forbidden: "越权操作" },
|
||||
witnessSpec: "client/tests/e2e/local-candidate-witnesses.spec.ts",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function writeFixture({
|
||||
routes = ["/core"],
|
||||
coreBundles = ["core-flow"],
|
||||
manifestRoutes = [
|
||||
{
|
||||
path: "/core",
|
||||
classification: "core",
|
||||
bundle: "core-flow",
|
||||
behaviorSpecs: ["client/tests/e2e/core.spec.ts"],
|
||||
},
|
||||
],
|
||||
localCandidateFlows = [candidateFlow()],
|
||||
acceptanceSpecs = ["client/tests/e2e/acceptance.spec.ts"],
|
||||
specs = {},
|
||||
} = {}) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "hyb-flow-coverage-"));
|
||||
const routerPath = path.join(root, "client/src/router/index.tsx");
|
||||
const manifestPath = path.join(root, "scripts/qa/route-manifest.json");
|
||||
mkdirSync(path.dirname(routerPath), { recursive: true });
|
||||
mkdirSync(path.dirname(manifestPath), { recursive: true });
|
||||
writeFileSync(
|
||||
routerPath,
|
||||
`export function RouterFixture() { return <Routes>${routes.map((route) => `<Route path="${route}" element={<div />} />`).join("")}</Routes>; }\n`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
manifestPath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
coreBundles,
|
||||
acceptanceSpecs,
|
||||
localCandidateFlows,
|
||||
routes: manifestRoutes,
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const defaultSpecs = {
|
||||
"client/tests/e2e/core.spec.ts": 'import { test } from "@playwright/test";\ntest("core", async ({ page }) => { await page.goto("/core"); });\n',
|
||||
"client/tests/e2e/acceptance.spec.ts": 'import { test } from "@playwright/test";\ntest("acceptance", async ({ page }) => { await page.goto("/core"); });\n',
|
||||
"client/tests/e2e/local-candidate-witnesses.spec.ts": WITNESS_SPEC,
|
||||
...specs,
|
||||
};
|
||||
for (const [relativePath, source] of Object.entries(defaultSpecs)) {
|
||||
if (source == null) continue;
|
||||
const file = path.join(root, relativePath);
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, source, "utf8");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function runFixture(root) {
|
||||
const report = buildReport(root);
|
||||
return { status: hasBlockingGaps(report) ? 1 : 0, report };
|
||||
}
|
||||
|
||||
function withFixture(options, callback) {
|
||||
const root = writeFixture(options);
|
||||
try {
|
||||
callback(root);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("完整 manifest、acceptance spec 与可信 witness 通过", () => {
|
||||
withFixture({}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.report.summary.blockingGapCount, 0);
|
||||
assert.equal(result.report.summary.coveredCoreBundleCount, 1);
|
||||
assert.equal(result.report.summary.coveredWitnessCount, 1);
|
||||
assert.equal(result.report.summary.validAcceptanceSpecCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("未分类注册路由必须阻断", () => {
|
||||
withFixture({ routes: ["/core", "/unmapped"] }, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.unclassified, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("403 与 wildcard 注册路由不能从分类扫描中豁免", () => {
|
||||
withFixture({ routes: ["/core", "/403", "*"] }, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.routeCount, 3);
|
||||
assert.equal(result.report.summary.gapCounts.unclassified, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test("manual 或 live spec 不能充当核心行为证据", () => {
|
||||
withFixture({
|
||||
manifestRoutes: [
|
||||
{
|
||||
path: "/core",
|
||||
classification: "core",
|
||||
bundle: "core-flow",
|
||||
behaviorSpecs: ["client/tests/e2e/manual/core.spec.ts"],
|
||||
},
|
||||
],
|
||||
specs: {
|
||||
"client/tests/e2e/manual/core.spec.ts": 'import { test } from "@playwright/test";\ntest("manual", async ({ page }) => { await page.goto("/core"); });\n',
|
||||
},
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.manual_only, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("核心路由缺失行为测试必须阻断", () => {
|
||||
withFixture({
|
||||
manifestRoutes: [{ path: "/core", classification: "core", bundle: "core-flow" }],
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.missing_behavior, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("visualRequired 缺失可执行视觉 spec 必须阻断", () => {
|
||||
withFixture({
|
||||
manifestRoutes: [
|
||||
{
|
||||
path: "/core",
|
||||
classification: "core",
|
||||
bundle: "core-flow",
|
||||
behaviorSpecs: ["client/tests/e2e/core.spec.ts"],
|
||||
visualRequired: true,
|
||||
},
|
||||
],
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.missing_visual, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("核心 bundle 缺失 witness 必须阻断", () => {
|
||||
withFixture({ localCandidateFlows: [] }, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.missing_local_candidate_witness, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("重复 bundle 与重复 witness tag 必须阻断", () => {
|
||||
withFixture({ localCandidateFlows: [candidateFlow(), candidateFlow()] }, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.duplicate_local_candidate_bundle, 1);
|
||||
assert.equal(result.report.summary.gapCounts.duplicate_witness_tag, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("未知 local-candidate bundle 必须阻断", () => {
|
||||
withFixture({
|
||||
localCandidateFlows: [candidateFlow(), candidateFlow("unknown-flow", "/core")],
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.unknown_local_candidate_bundle, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("重复 core bundle 必须阻断", () => {
|
||||
withFixture({ coreBundles: ["core-flow", "core-flow"] }, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.duplicate_core_bundle, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("orphan manifest 路由必须阻断", () => {
|
||||
withFixture({
|
||||
manifestRoutes: [
|
||||
{
|
||||
path: "/core",
|
||||
classification: "core",
|
||||
bundle: "core-flow",
|
||||
behaviorSpecs: ["client/tests/e2e/core.spec.ts"],
|
||||
},
|
||||
{ path: "/orphan", classification: "supported-noncore" },
|
||||
],
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.orphan_manifest_route, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("witnessRoute 指向未注册路由必须阻断", () => {
|
||||
withFixture({ localCandidateFlows: [candidateFlow("core-flow", "/missing")] }, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.orphan_witness_route, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("witnessRoute 绑定到其他 bundle 必须阻断", () => {
|
||||
const otherSpec = 'import { test } from "@playwright/test";\ntest("other", async ({ page }) => { await page.goto("/other"); });\n';
|
||||
withFixture({
|
||||
routes: ["/core", "/other"],
|
||||
coreBundles: ["core-flow", "other-flow"],
|
||||
manifestRoutes: [
|
||||
{
|
||||
path: "/core",
|
||||
classification: "core",
|
||||
bundle: "core-flow",
|
||||
behaviorSpecs: ["client/tests/e2e/core.spec.ts"],
|
||||
},
|
||||
{
|
||||
path: "/other",
|
||||
classification: "core",
|
||||
bundle: "other-flow",
|
||||
behaviorSpecs: ["client/tests/e2e/other.spec.ts"],
|
||||
},
|
||||
],
|
||||
localCandidateFlows: [candidateFlow("core-flow", "/other"), candidateFlow("other-flow", "/other")],
|
||||
specs: { "client/tests/e2e/other.spec.ts": otherSpec },
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.wrong_witness_association, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("witness 字段不完整或 tag 不稳定必须阻断", () => {
|
||||
withFixture({
|
||||
localCandidateFlows: [candidateFlow("core-flow", "/core", {
|
||||
witnessTag: "@local-candidate:wrong",
|
||||
expectedH1: "",
|
||||
roleFixture: "unknown",
|
||||
primaryAction: { allowed: "相同", forbidden: "相同" },
|
||||
})],
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.invalid_witness_tag, 1);
|
||||
assert.equal(result.report.summary.gapCounts.missing_expected_h1, 1);
|
||||
assert.equal(result.report.summary.gapCounts.invalid_role_fixture, 1);
|
||||
assert.equal(result.report.summary.gapCounts.invalid_primary_action, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("manual/live acceptance spec 必须阻断", () => {
|
||||
withFixture({
|
||||
acceptanceSpecs: ["client/tests/e2e/manual/acceptance.spec.ts"],
|
||||
specs: {
|
||||
"client/tests/e2e/manual/acceptance.spec.ts": 'import { test } from "@playwright/test";\ntest("manual", async () => {});\n',
|
||||
},
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.report.summary.gapCounts.manual_or_live_acceptance_spec, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("候选 spec 不同路径使用同一 basename 必须阻断", () => {
|
||||
withFixture({
|
||||
acceptanceSpecs: ["client/tests/e2e/nested/local-candidate-witnesses.spec.ts"],
|
||||
specs: {
|
||||
"client/tests/e2e/nested/local-candidate-witnesses.spec.ts":
|
||||
'import { test } from "@playwright/test";\ntest("acceptance", async ({ page }) => { await page.goto("/core"); });\n',
|
||||
},
|
||||
}, (root) => {
|
||||
const result = runFixture(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.deepEqual(result.report.duplicateCandidateSpecBasenames, ["local-candidate-witnesses.spec.ts"]);
|
||||
assert.equal(result.report.summary.gapCounts.duplicate_candidate_spec_basename, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
|
||||
const sourceScript = path.join(repoRoot, "scripts/check-minimal.sh");
|
||||
const localCandidateRunner = path.join(repoRoot, "scripts/qa/run-local-candidate.sh");
|
||||
|
||||
function writeExecutable(file, source) {
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, source, "utf8");
|
||||
chmodSync(file, 0o755);
|
||||
}
|
||||
|
||||
test("local-candidate 基础 profile 在没有 issues 目录时不调用历史 CSV 门禁", () => {
|
||||
const fixtureRoot = mkdtempSync(path.join(tmpdir(), "hyb-local-candidate-profile-"));
|
||||
try {
|
||||
const scriptSource = readFileSync(sourceScript, "utf8");
|
||||
const fixtureScript = path.join(fixtureRoot, "scripts/check-minimal.sh");
|
||||
writeExecutable(fixtureScript, scriptSource);
|
||||
mkdirSync(path.join(fixtureRoot, "server"), { recursive: true });
|
||||
mkdirSync(path.join(fixtureRoot, "client"), { recursive: true });
|
||||
|
||||
for (const match of scriptSource.matchAll(/\bbash -n ([^\s]+)/gu)) {
|
||||
const relativeFile = match[1];
|
||||
for (const base of [fixtureRoot, path.join(fixtureRoot, "server")]) {
|
||||
const file = path.join(base, relativeFile);
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, "#!/usr/bin/env bash\n", "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
const binDir = path.join(fixtureRoot, "bin");
|
||||
const commandLog = path.join(fixtureRoot, "commands.log");
|
||||
writeExecutable(path.join(binDir, "node"), `#!/usr/bin/env bash
|
||||
printf 'node %s\\n' "$*" >> "$CHECK_MINIMAL_COMMAND_LOG"
|
||||
if [[ "$*" == *check-process-contract.mjs* ]]; then exit 91; fi
|
||||
exit 0
|
||||
`);
|
||||
writeExecutable(path.join(binDir, "npm"), `#!/usr/bin/env bash
|
||||
printf 'npm %s\\n' "$*" >> "$CHECK_MINIMAL_COMMAND_LOG"
|
||||
exit 0
|
||||
`);
|
||||
writeExecutable(path.join(binDir, "python3"), `#!/usr/bin/env bash
|
||||
printf 'python3 %s\\n' "$*" >> "$CHECK_MINIMAL_COMMAND_LOG"
|
||||
exit 92
|
||||
`);
|
||||
|
||||
const result = spawnSync("bash", [fixtureScript, "local-candidate"], {
|
||||
cwd: fixtureRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
||||
CHECK_MINIMAL_COMMAND_LOG: commandLog,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
const commands = readFileSync(commandLog, "utf8");
|
||||
assert.doesNotMatch(commands, /check-process-contract|validate-feature-matrix|python3/u);
|
||||
assert.equal(existsSync(path.join(fixtureRoot, "issues")), false);
|
||||
} finally {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("local-candidate 结果标签严格区分 full、focused 与 list", () => {
|
||||
const source = readFileSync(localCandidateRunner, "utf8");
|
||||
const blockStart = source.lastIndexOf('if [[ "$MODE" == "full" ]]; then');
|
||||
const blockEnd = source.indexOf("\nfi", blockStart);
|
||||
|
||||
assert.notEqual(blockStart, -1, "缺少 full/focused 结果标签分支");
|
||||
assert.notEqual(blockEnd, -1, "full/focused 结果标签分支不完整");
|
||||
const resultLabelBlock = source.slice(blockStart, blockEnd + "\nfi".length);
|
||||
|
||||
const runLabelBlock = (mode) => spawnSync("bash", ["-c", resultLabelBlock], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, MODE: mode },
|
||||
});
|
||||
const full = runLabelBlock("full");
|
||||
const focused = runLabelBlock("focused");
|
||||
|
||||
assert.equal(full.status, 0, full.stderr);
|
||||
assert.match(full.stdout, /LOCAL_CANDIDATE_PASS/u);
|
||||
assert.doesNotMatch(full.stdout, /FOCUSED_BROWSER_PASS/u);
|
||||
assert.equal(focused.status, 0, focused.stderr);
|
||||
assert.match(focused.stdout, /FOCUSED_BROWSER_PASS/u);
|
||||
assert.doesNotMatch(focused.stdout, /LOCAL_CANDIDATE_PASS/u);
|
||||
assert.equal(source.match(/LOCAL_CANDIDATE_PASS/gu)?.length, 1);
|
||||
assert.match(
|
||||
source,
|
||||
/if \[\[ "\$MODE" == "list" \]\]; then[\s\S]*?LIST_ONLY[\s\S]*?exit 0[\s\S]*?\nfi/u,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { validateLoopbackHttpUrl, validateTcpPort } from "../validate-loopback-url.mjs";
|
||||
|
||||
for (const [input, expected] of [
|
||||
["http://127.0.0.1:5178", "http://127.0.0.1:5178"],
|
||||
["http://localhost:3000/", "http://localhost:3000"],
|
||||
["http://[::1]:4173", "http://[::1]:4173"],
|
||||
]) {
|
||||
test(`accepts loopback origin ${input}`, () => {
|
||||
assert.equal(validateLoopbackHttpUrl(input), expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of ["1", "80", "5178", "65535"]) {
|
||||
test(`accepts canonical TCP port ${input}`, () => {
|
||||
assert.equal(validateTcpPort(input), input);
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of ["", "0", "065", "65536", "5178/path", "[email protected]", "1.5", "-1"]) {
|
||||
test(`rejects ambiguous or invalid TCP port ${input || "<empty>"}`, () => {
|
||||
assert.throws(() => validateTcpPort(input));
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of [
|
||||
"https://127.0.0.1:5178",
|
||||
"http://example.invalid:5178",
|
||||
"http://localhost.example.invalid:5178",
|
||||
"http://[email protected]:5178",
|
||||
"http://remote.invalid@localhost:5178",
|
||||
"http://user:[email protected]:5178",
|
||||
"http://127.0.0.1:5178/path",
|
||||
"not-a-url",
|
||||
]) {
|
||||
test(`rejects non-loopback or ambiguous target ${input}`, () => {
|
||||
assert.throws(() => validateLoopbackHttpUrl(input));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { verifyLocalCandidateResults } from "../verify-local-candidate-results.mjs";
|
||||
|
||||
const bundles = [
|
||||
"login-logout",
|
||||
"reimbursement",
|
||||
"approval",
|
||||
"invoice",
|
||||
"procurement",
|
||||
"project-contract",
|
||||
"payment-ap",
|
||||
"people-audit",
|
||||
"attendance-archive",
|
||||
"mobile-workspace",
|
||||
];
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
localCandidateFlows: bundles.map((bundle) => ({
|
||||
bundle,
|
||||
witnessTag: `@local-candidate:${bundle}`,
|
||||
witnessSpec: "client/tests/e2e/synthetic-local-candidate.spec.ts",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function spec(tag, status = "passed", overrides = {}) {
|
||||
return {
|
||||
title: tag,
|
||||
file: "client/tests/e2e/synthetic-local-candidate.spec.ts",
|
||||
line: 12,
|
||||
tags: [],
|
||||
tests: [
|
||||
{
|
||||
expectedStatus: "passed",
|
||||
results: [{ status }],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function results(specs, overrides = {}) {
|
||||
return {
|
||||
suites: [{ title: "local candidate", specs }],
|
||||
errors: [],
|
||||
stats: { expected: specs.length, skipped: 0, unexpected: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("10 个唯一 witness tag 全部 passed 时通过", () => {
|
||||
const summary = verifyLocalCandidateResults(
|
||||
manifest(),
|
||||
results(bundles.map((bundle) => spec(`@local-candidate:${bundle}`))),
|
||||
);
|
||||
assert.equal(summary.ok, true);
|
||||
assert.equal(summary.passedTagCount, 10);
|
||||
assert.equal(summary.skippedTagCount, 0);
|
||||
});
|
||||
|
||||
test("结果缺失 tag 必须阻断", () => {
|
||||
const summary = verifyLocalCandidateResults(
|
||||
manifest(),
|
||||
results(bundles.slice(1).map((bundle) => spec(`@local-candidate:${bundle}`))),
|
||||
);
|
||||
assert.equal(summary.ok, false);
|
||||
assert.deepEqual(summary.missingTags, ["@local-candidate:login-logout"]);
|
||||
assert.ok(summary.issues.includes("missing_tag_result"));
|
||||
});
|
||||
|
||||
test("重复 tag 结果必须阻断", () => {
|
||||
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`));
|
||||
specs.push(spec("@local-candidate:login-logout"));
|
||||
const summary = verifyLocalCandidateResults(manifest(), results(specs));
|
||||
assert.equal(summary.ok, false);
|
||||
assert.deepEqual(summary.duplicateTags, ["@local-candidate:login-logout"]);
|
||||
});
|
||||
|
||||
test("未知 tag 结果必须阻断", () => {
|
||||
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`));
|
||||
specs.push(spec("@local-candidate:unknown"));
|
||||
const summary = verifyLocalCandidateResults(manifest(), results(specs));
|
||||
assert.equal(summary.ok, false);
|
||||
assert.deepEqual(summary.unknownTags, ["@local-candidate:unknown"]);
|
||||
});
|
||||
|
||||
test("全部 witness skipped 必须阻断", () => {
|
||||
const summary = verifyLocalCandidateResults(
|
||||
manifest(),
|
||||
results(bundles.map((bundle) => spec(`@local-candidate:${bundle}`, "skipped"))),
|
||||
);
|
||||
assert.equal(summary.ok, false);
|
||||
assert.equal(summary.skippedTagCount, 10);
|
||||
assert.equal(summary.passedTagCount, 0);
|
||||
assert.ok(summary.issues.includes("skipped_tag_result"));
|
||||
});
|
||||
|
||||
test("任一 witness 或整体 Playwright suite 失败必须阻断", () => {
|
||||
const specs = bundles.map((bundle) =>
|
||||
spec(`@local-candidate:${bundle}`, bundle === "payment-ap" ? "failed" : "passed"),
|
||||
);
|
||||
const summary = verifyLocalCandidateResults(
|
||||
manifest(),
|
||||
results(specs, { errors: [{}], stats: { expected: 9, skipped: 0, unexpected: 1 } }),
|
||||
);
|
||||
assert.equal(summary.ok, false);
|
||||
assert.deepEqual(summary.failedTags, ["@local-candidate:payment-ap"]);
|
||||
assert.ok(summary.issues.includes("playwright_suite_failed"));
|
||||
assert.deepEqual(summary.failedTests, [{
|
||||
file: "synthetic-local-candidate.spec.ts",
|
||||
line: 12,
|
||||
title: "@local-candidate:payment-ap",
|
||||
status: "failed",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("非 witness acceptance 用例 skipped 也必须阻断且诊断不泄露绝对路径", () => {
|
||||
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`));
|
||||
specs.push(spec("本地附加验收", "skipped", {
|
||||
file: "/private/workspace/client/tests/e2e/acceptance.spec.ts",
|
||||
line: 27,
|
||||
}));
|
||||
const summary = verifyLocalCandidateResults(
|
||||
manifest(),
|
||||
results(specs, { stats: { expected: 10, skipped: 1, unexpected: 0 } }),
|
||||
);
|
||||
assert.equal(summary.ok, false);
|
||||
assert.ok(summary.issues.includes("playwright_suite_skipped"));
|
||||
assert.equal(summary.suiteSkippedTestCount, 1);
|
||||
assert.deepEqual(summary.skippedTests, [{
|
||||
file: "acceptance.spec.ts",
|
||||
line: 27,
|
||||
title: "本地附加验收",
|
||||
status: "skipped",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("单一测试合并多个 witness tag 不能冒充十个独立流程", () => {
|
||||
const mergedTitle = bundles.map((bundle) => `@local-candidate:${bundle}`).join(" ");
|
||||
const summary = verifyLocalCandidateResults(manifest(), results([spec(mergedTitle)]));
|
||||
assert.equal(summary.ok, false);
|
||||
assert.equal(summary.totalTestResultCount, 1);
|
||||
assert.ok(summary.issues.includes("multi_tag_test_result"));
|
||||
assert.equal(summary.multiTagTests.length, 1);
|
||||
});
|
||||
|
||||
test("witness tag 必须来自 manifest 指定的测试文件", () => {
|
||||
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`));
|
||||
specs[0] = spec("@local-candidate:login-logout", "passed", {
|
||||
file: "/private/workspace/client/tests/e2e/unrelated.spec.ts",
|
||||
});
|
||||
const summary = verifyLocalCandidateResults(manifest(), results(specs));
|
||||
assert.equal(summary.ok, false);
|
||||
assert.ok(summary.issues.includes("wrong_witness_spec_result"));
|
||||
assert.deepEqual(summary.wrongWitnessSpecs, [{
|
||||
tag: "@local-candidate:login-logout",
|
||||
expectedFile: "synthetic-local-candidate.spec.ts",
|
||||
observedFile: "unrelated.spec.ts",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("Playwright JSON 将 file 放在父 suite 时仍能核对 witness 来源", () => {
|
||||
const specs = bundles.map((bundle) => {
|
||||
const item = spec(`@local-candidate:${bundle}`);
|
||||
delete item.file;
|
||||
return item;
|
||||
});
|
||||
const report = results([], {
|
||||
suites: [{
|
||||
title: "local candidate witnesses",
|
||||
file: "tests/e2e/synthetic-local-candidate.spec.ts",
|
||||
specs,
|
||||
}],
|
||||
});
|
||||
const summary = verifyLocalCandidateResults(manifest(), report);
|
||||
assert.equal(summary.ok, true);
|
||||
assert.deepEqual(summary.wrongWitnessSpecs, []);
|
||||
});
|
||||
|
||||
test("Playwright JSON 只提供 spec basename 时按 manifest 唯一 basename 核对", () => {
|
||||
const specs = bundles.map((bundle) => spec(`@local-candidate:${bundle}`, "passed", {
|
||||
file: "synthetic-local-candidate.spec.ts",
|
||||
}));
|
||||
const summary = verifyLocalCandidateResults(manifest(), results(specs));
|
||||
assert.equal(summary.ok, true);
|
||||
assert.deepEqual(summary.wrongWitnessSpecs, []);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
||||
|
||||
export function validateLoopbackHttpUrl(input) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(input ?? ""));
|
||||
} catch {
|
||||
throw new Error("base_url_invalid");
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:") throw new Error("base_url_protocol_not_http");
|
||||
if (url.username || url.password) throw new Error("base_url_credentials_forbidden");
|
||||
if (!LOOPBACK_HOSTS.has(url.hostname)) throw new Error("base_url_host_not_loopback");
|
||||
if ((url.pathname && url.pathname !== "/") || url.search || url.hash) {
|
||||
throw new Error("base_url_must_be_origin_only");
|
||||
}
|
||||
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export function validateTcpPort(input) {
|
||||
const raw = String(input ?? "");
|
||||
if (!/^[1-9][0-9]{0,4}$/u.test(raw)) throw new Error("port_invalid");
|
||||
const port = Number(raw);
|
||||
if (!Number.isSafeInteger(port) || port > 65535) throw new Error("port_out_of_range");
|
||||
return String(port);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
try {
|
||||
console.log(
|
||||
process.argv[2] === "--port"
|
||||
? validateTcpPort(process.argv[3])
|
||||
: validateLoopbackHttpUrl(process.argv[2]),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[local-candidate][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const WITNESS_TAG_PATTERN = /@local-candidate:[a-z0-9-]+/g;
|
||||
const FAILED_STATUSES = new Set(["failed", "timedOut", "interrupted"]);
|
||||
|
||||
function collectSpecs(suites, output = [], inheritedFile = "") {
|
||||
for (const suite of Array.isArray(suites) ? suites : []) {
|
||||
const suiteFile = suite?.file ?? inheritedFile;
|
||||
for (const spec of Array.isArray(suite?.specs) ? suite.specs : []) {
|
||||
output.push({ ...spec, __sourceFile: spec?.file ?? suiteFile });
|
||||
}
|
||||
collectSpecs(suite?.suites, output, suiteFile);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function tagsForSpec(spec) {
|
||||
const candidates = [
|
||||
...(String(spec?.title ?? "").match(WITNESS_TAG_PATTERN) ?? []),
|
||||
...(Array.isArray(spec?.tags) ? spec.tags : []).flatMap((tag) => String(tag).match(WITNESS_TAG_PATTERN) ?? []),
|
||||
];
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function finalStatus(test) {
|
||||
const results = Array.isArray(test?.results) ? test.results : [];
|
||||
return results.at(-1)?.status ?? test?.status ?? "missing";
|
||||
}
|
||||
|
||||
function safeDiagnosticText(value, maxLength = 160) {
|
||||
return String(value ?? "")
|
||||
.replace(/[\r\n\t]+/gu, " ")
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeSpecDiagnostic(spec, status) {
|
||||
const line = Number(spec?.line);
|
||||
return {
|
||||
file: path.basename(safeDiagnosticText(spec?.__sourceFile ?? spec?.file, 240)) || "unknown-spec",
|
||||
line: Number.isSafeInteger(line) && line > 0 ? line : null,
|
||||
title: safeDiagnosticText(spec?.title) || "unknown-test",
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWitnessSpecFile(file) {
|
||||
const normalized = String(file ?? "").replaceAll("\\", "/");
|
||||
if (normalized.startsWith("client/tests/e2e/")) return normalized;
|
||||
if (normalized.startsWith("tests/e2e/")) return `client/${normalized}`;
|
||||
const marker = "/client/tests/e2e/";
|
||||
const markerIndex = normalized.lastIndexOf(marker);
|
||||
if (markerIndex >= 0) return normalized.slice(markerIndex + 1);
|
||||
if (/^[^/]+\.spec\.[cm]?[jt]sx?$/u.test(normalized)) return normalized;
|
||||
return "";
|
||||
}
|
||||
|
||||
function witnessSpecMatches(observedFile, expectedFile) {
|
||||
if (!observedFile || !expectedFile) return false;
|
||||
if (observedFile.includes("/")) return observedFile === expectedFile;
|
||||
return observedFile === path.basename(expectedFile);
|
||||
}
|
||||
|
||||
function duplicates(values) {
|
||||
const counts = new Map();
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
return [...counts.entries()].filter(([, count]) => count > 1).map(([value]) => value).sort();
|
||||
}
|
||||
|
||||
export function verifyLocalCandidateResults(manifest, results, { expectedTagCount = 10 } = {}) {
|
||||
const flows = Array.isArray(manifest?.localCandidateFlows) ? manifest.localCandidateFlows : [];
|
||||
const expectedTags = flows.map((flow) => flow?.witnessTag).filter((tag) => typeof tag === "string");
|
||||
const expectedTagSet = new Set(expectedTags);
|
||||
const expectedSpecByTag = new Map(
|
||||
flows.map((flow) => [flow?.witnessTag, normalizeWitnessSpecFile(flow?.witnessSpec)]),
|
||||
);
|
||||
const manifestDuplicateTags = duplicates(expectedTags);
|
||||
const manifestInvalidTags = flows
|
||||
.filter((flow) => flow?.witnessTag !== `@local-candidate:${flow?.bundle ?? ""}`)
|
||||
.map((flow) => String(flow?.witnessTag ?? ""))
|
||||
.sort();
|
||||
|
||||
const observed = [];
|
||||
const allTests = [];
|
||||
const multiTagTests = [];
|
||||
const wrongWitnessSpecs = [];
|
||||
for (const spec of collectSpecs(results?.suites)) {
|
||||
const tags = tagsForSpec(spec);
|
||||
const specFile = normalizeWitnessSpecFile(spec?.__sourceFile ?? spec?.file);
|
||||
const tests = Array.isArray(spec?.tests) ? spec.tests : [];
|
||||
for (const test of tests) {
|
||||
const status = finalStatus(test);
|
||||
allTests.push(safeSpecDiagnostic(spec, status));
|
||||
if (tags.length > 1) multiTagTests.push(safeSpecDiagnostic(spec, status));
|
||||
for (const tag of tags) {
|
||||
observed.push({ tag, status });
|
||||
const expectedSpec = expectedSpecByTag.get(tag);
|
||||
if (!witnessSpecMatches(specFile, expectedSpec)) {
|
||||
wrongWitnessSpecs.push({
|
||||
tag,
|
||||
expectedFile: path.basename(expectedSpec || "unknown-spec"),
|
||||
observedFile: path.basename(specFile || "unknown-spec"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const observedTags = observed.map((item) => item.tag);
|
||||
const duplicateTags = duplicates(observedTags);
|
||||
const unknownTags = [...new Set(observedTags.filter((tag) => !expectedTagSet.has(tag)))].sort();
|
||||
const missingTags = expectedTags.filter((tag) => !observedTags.includes(tag)).sort();
|
||||
const skippedTags = [...new Set(observed.filter((item) => item.status === "skipped").map((item) => item.tag))].sort();
|
||||
const failedTags = [...new Set(observed.filter((item) => item.status !== "passed" && item.status !== "skipped").map((item) => item.tag))].sort();
|
||||
const passedTags = [...new Set(observed.filter((item) => item.status === "passed").map((item) => item.tag))].sort();
|
||||
const suiteFailedTestCount = allTests.filter((item) => FAILED_STATUSES.has(item.status)).length;
|
||||
const suiteSkippedTestCount = allTests.filter((item) => item.status === "skipped").length;
|
||||
const suiteUnexpectedCount = Number.isFinite(results?.stats?.unexpected) ? Number(results.stats.unexpected) : 0;
|
||||
const suiteReportedSkippedCount = Number.isFinite(results?.stats?.skipped) ? Number(results.stats.skipped) : 0;
|
||||
const resultErrorCount = Array.isArray(results?.errors) ? results.errors.length : 0;
|
||||
const failedTests = allTests.filter((item) => FAILED_STATUSES.has(item.status)).slice(0, 20);
|
||||
const skippedTests = allTests.filter((item) => item.status === "skipped").slice(0, 20);
|
||||
|
||||
const issues = [];
|
||||
if (expectedTags.length !== expectedTagCount) issues.push("unexpected_expected_tag_count");
|
||||
if (manifestDuplicateTags.length) issues.push("manifest_duplicate_tag");
|
||||
if (manifestInvalidTags.length) issues.push("manifest_invalid_tag");
|
||||
if (missingTags.length) issues.push("missing_tag_result");
|
||||
if (duplicateTags.length) issues.push("duplicate_tag_result");
|
||||
if (unknownTags.length) issues.push("unknown_tag_result");
|
||||
if (multiTagTests.length) issues.push("multi_tag_test_result");
|
||||
if (wrongWitnessSpecs.length) issues.push("wrong_witness_spec_result");
|
||||
if (skippedTags.length) issues.push("skipped_tag_result");
|
||||
if (failedTags.length) issues.push("failed_tag_result");
|
||||
if (passedTags.length !== expectedTags.length) issues.push("incomplete_passed_tag_set");
|
||||
if (suiteFailedTestCount > 0 || suiteUnexpectedCount > 0 || resultErrorCount > 0) issues.push("playwright_suite_failed");
|
||||
if (suiteSkippedTestCount > 0 || suiteReportedSkippedCount > 0) issues.push("playwright_suite_skipped");
|
||||
|
||||
return {
|
||||
ok: issues.length === 0,
|
||||
issues,
|
||||
expectedTagCount: expectedTags.length,
|
||||
passedTagCount: passedTags.length,
|
||||
skippedTagCount: skippedTags.length,
|
||||
failedTagCount: failedTags.length,
|
||||
missingTags,
|
||||
duplicateTags,
|
||||
unknownTags,
|
||||
skippedTags,
|
||||
failedTags,
|
||||
manifestDuplicateTags,
|
||||
manifestInvalidTags,
|
||||
multiTagTests,
|
||||
wrongWitnessSpecs,
|
||||
totalTestResultCount: allTests.length,
|
||||
suiteFailedTestCount,
|
||||
suiteSkippedTestCount,
|
||||
suiteUnexpectedCount,
|
||||
suiteReportedSkippedCount,
|
||||
resultErrorCount,
|
||||
failedTests,
|
||||
skippedTests,
|
||||
};
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const manifestIndex = argv.indexOf("--manifest");
|
||||
const resultsIndex = argv.indexOf("--results");
|
||||
if (manifestIndex < 0 || resultsIndex < 0 || !argv[manifestIndex + 1] || !argv[resultsIndex + 1]) {
|
||||
throw new Error("missing_arguments");
|
||||
}
|
||||
return {
|
||||
manifestPath: path.resolve(argv[manifestIndex + 1]),
|
||||
resultsPath: path.resolve(argv[resultsIndex + 1]),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const args = parseArgs();
|
||||
const summary = verifyLocalCandidateResults(readJson(args.manifestPath), readJson(args.resultsPath));
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
if (!summary.ok) process.exitCode = 1;
|
||||
} catch {
|
||||
console.log(JSON.stringify({ ok: false, issues: ["invalid_or_missing_result_json"] }, null, 2));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main();
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户