import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import {
createDocxReleaseGateFingerprint,
createDocxReleaseGateSuites,
DOCX_RELEASE_GATE_SCHEMA_VERSION,
readJsonIfExists,
resolveDocxReleaseGateSuite,
summarizeSuiteProgress,
writeJsonAtomic
} from "./docx-release-gate-suites.mjs";
const execFileAsync = promisify(execFile);
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const matrixScript = path.join(repositoryDirectory, "scripts", "verify-docx-layout-visual-matrix.mjs");
const repositoryVersion = JSON.parse(
fs.readFileSync(path.join(repositoryDirectory, "package.json"), "utf8")
).version;
const outputRoot = path.resolve(
repositoryDirectory,
process.env.MD_TO_PDF_RELEASE_GATE_OUTPUT_DIR?.trim() ||
path.join("output", "docx-release-gate", `v${repositoryVersion}`)
);
const requestedSuiteId = process.env.MD_TO_PDF_RELEASE_GATE_SUITE?.trim() || "next";
const selectedCaseId = process.env.MD_TO_PDF_RELEASE_GATE_CASE?.trim() || undefined;
const allowOutOfOrder =
process.env.MD_TO_PDF_RELEASE_GATE_ALLOW_OUT_OF_ORDER?.trim() === "1";
const requestedCaseLimitText =
process.env.MD_TO_PDF_RELEASE_GATE_CASE_LIMIT?.trim() || undefined;
const requestedCaseLimit = requestedCaseLimitText
? Number.parseInt(requestedCaseLimitText, 10)
: undefined;
if (
requestedCaseLimitText &&
(!Number.isInteger(requestedCaseLimit) || requestedCaseLimit <= 0)
) {
throw new Error("MD_TO_PDF_RELEASE_GATE_CASE_LIMIT 必须是正整数");
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function readAggregates(suites) {
return new Map(suites.map((suite) => {
const aggregate = readJsonIfExists(
path.join(outputRoot, suite.outputDirectoryName, "aggregate.json")
);
const currentFingerprint = createDocxReleaseGateFingerprint(
repositoryDirectory,
suite
);
return [
suite.id,
aggregate?.fingerprint === currentFingerprint ? aggregate : undefined
];
}));
}
function renderSuiteHtml(aggregate) {
const rows = aggregate.cases.map((entry) =>
`
| ${escapeHtml(entry.caseId)} | ${escapeHtml(entry.status)} | ${escapeHtml(entry.finishedAt || "")} | ${entry.summaryPath ? `JSON` : "-"} | ${escapeHtml(entry.error || entry.gateFailures?.join(";") || "")} |
`
).join("");
return `${escapeHtml(aggregate.suite.label)}${escapeHtml(aggregate.suite.label)}
${aggregate.execution.completedCaseCount}/${aggregate.execution.expectedCaseCount};通过 ${aggregate.execution.passedCaseCount};门禁失败 ${aggregate.execution.gateFailedCaseCount};基础设施错误 ${aggregate.execution.errorCaseCount}。
`;
}
function buildAggregate({ suite, fingerprint, records }) {
const execution = summarizeSuiteProgress({ suite, fingerprint, records });
return {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
fingerprint,
suite: {
id: suite.id,
label: suite.label,
kind: suite.kind,
order: suite.order,
markdownPath: suite.markdownPath,
markdownByteLength: suite.markdownByteLength
},
execution,
gatePassed: execution.gatePassed,
cases: suite.caseIds.map((caseId) => records[caseId])
.filter((record) => record?.fingerprint === fingerprint)
};
}
function writeSuiteReports({ suiteDirectory, suite, fingerprint, records }) {
const aggregate = buildAggregate({ suite, fingerprint, records });
writeJsonAtomic(path.join(suiteDirectory, "progress.json"), {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
updatedAt: aggregate.generatedAt,
fingerprint,
records
});
writeJsonAtomic(path.join(suiteDirectory, "aggregate.json"), aggregate);
fs.writeFileSync(path.join(suiteDirectory, "aggregate.html"), renderSuiteHtml(aggregate), "utf8");
return aggregate;
}
function writeTopLevelIndex(suites) {
const entries = suites.map((suite) => ({
suite: {
id: suite.id,
label: suite.label,
kind: suite.kind,
order: suite.order,
outputDirectoryName: suite.outputDirectoryName,
markdownByteLength: suite.markdownByteLength
},
aggregate: readJsonIfExists(path.join(outputRoot, suite.outputDirectoryName, "aggregate.json"))
}));
const index = {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
strategy: "four-independent-140-suites",
totalExpectedCaseCount: 560,
entries
};
writeJsonAtomic(path.join(outputRoot, "index.json"), index);
const rows = entries.map(({ suite, aggregate }) =>
`| ${suite.order + 1} | ${escapeHtml(suite.label)} | ${aggregate?.execution?.completedCaseCount ?? 0}/140 | ${aggregate?.gatePassed ? "通过" : aggregate ? "未通过/未完成" : "未开始"} | 报告 |
`
).join("");
fs.writeFileSync(path.join(outputRoot, "index.html"), `DOCX 4×140 发布门禁DOCX 4×140 独立发布门禁
先通过标准合成基线,再按 Markdown 字节数从小到大逐套执行。
`, "utf8");
}
function parseChildSummary(stdout) {
const text = Buffer.isBuffer(stdout) ? stdout.toString("utf8") : String(stdout || "");
return JSON.parse(text.trim());
}
fs.mkdirSync(outputRoot, { recursive: true });
const suites = createDocxReleaseGateSuites(repositoryDirectory);
const suite = resolveDocxReleaseGateSuite({
suites,
requestedId: requestedSuiteId,
aggregates: readAggregates(suites),
allowOutOfOrder
});
if (!suite) {
writeTopLevelIndex(suites);
process.stdout.write("四套独立 140 门禁均已通过,无需重复执行。\n");
process.exit(0);
}
if (selectedCaseId && !suite.caseIds.includes(selectedCaseId)) {
throw new Error(`套件 ${suite.id} 不包含场景 ${selectedCaseId}`);
}
const suiteDirectory = path.join(outputRoot, suite.outputDirectoryName);
const logsDirectory = path.join(suiteDirectory, "logs");
fs.mkdirSync(logsDirectory, { recursive: true });
fs.writeFileSync(
path.join(suiteDirectory, "night-run.pid"),
`${process.pid}\n`,
"utf8"
);
const fingerprint = createDocxReleaseGateFingerprint(repositoryDirectory, suite);
const previousProgress = readJsonIfExists(path.join(suiteDirectory, "progress.json"));
const records = previousProgress?.records && typeof previousProgress.records === "object"
? previousProgress.records
: {};
const runStartedAt = new Date().toISOString();
const runPath = path.join(suiteDirectory, "run.json");
writeJsonAtomic(runPath, {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
pid: process.pid,
processStartedAt: runStartedAt,
command: process.argv,
suiteId: suite.id,
suiteFingerprint: fingerprint,
allowOutOfOrder,
...(requestedCaseLimit ? { requestedCaseLimit } : {}),
status: "running"
});
const requestedCaseIds = selectedCaseId
? [selectedCaseId]
: requestedCaseLimit
? suite.caseIds.slice(0, requestedCaseLimit)
: suite.caseIds;
for (const caseId of requestedCaseIds) {
if (records[caseId]?.fingerprint === fingerprint && records[caseId]?.status === "passed") {
process.stderr.write(`[DOCX gate ${suite.id}] ${caseId} 已通过,跳过\n`);
continue;
}
process.stderr.write(`[DOCX gate ${suite.id}] 执行 ${caseId}\n`);
const environment = {
...process.env,
MD_TO_PDF_LAYOUT_VISUAL_CASE: caseId,
MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR: suiteDirectory,
MD_TO_PDF_LAYOUT_VISUAL_SCOPE: suite.kind === "synthetic" ? "full" : "corpus",
MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES: suite.kind === "synthetic" ? "1" : "0"
};
if (suite.kind === "corpus") {
environment.MD_TO_PDF_CORPUS_ID = suite.id;
environment.MD_TO_PDF_R4_MARKDOWN_PATH = suite.markdownPath;
} else {
delete environment.MD_TO_PDF_CORPUS_ID;
delete environment.MD_TO_PDF_R4_MARKDOWN_PATH;
}
const startedAt = new Date().toISOString();
let record;
try {
const { stdout, stderr } = await execFileAsync(process.execPath, [matrixScript], {
cwd: repositoryDirectory,
windowsHide: true,
timeout: 24 * 60 * 60 * 1000,
maxBuffer: 256 * 1024 * 1024,
env: environment
});
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stdout.log`), stdout, "utf8");
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stderr.log`), stderr, "utf8");
const summary = parseChildSummary(stdout);
record = {
caseId,
fingerprint,
status: summary.gatePassed ? "passed" : "gate-failed",
startedAt,
finishedAt: new Date().toISOString(),
gateFailures: summary.gateFailures ?? [],
summaryPath: `summary-${caseId}.json`,
matrixPath: `matrix-${caseId}.html`
};
} catch (error) {
const stdout = Buffer.isBuffer(error?.stdout) ? error.stdout.toString("utf8") : String(error?.stdout || "");
const stderr = Buffer.isBuffer(error?.stderr) ? error.stderr.toString("utf8") : String(error?.stderr || "");
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stdout.log`), stdout, "utf8");
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stderr.log`), stderr || String(error?.stack || error), "utf8");
try {
const summary = parseChildSummary(stdout);
record = {
caseId,
fingerprint,
status: "gate-failed",
startedAt,
finishedAt: new Date().toISOString(),
gateFailures: summary.gateFailures ?? [String(error?.message || error)],
summaryPath: `summary-${caseId}.json`,
matrixPath: `matrix-${caseId}.html`
};
} catch {
record = {
caseId,
fingerprint,
status: "error",
startedAt,
finishedAt: new Date().toISOString(),
error: String(error?.message || error)
};
}
}
records[caseId] = record;
writeSuiteReports({ suiteDirectory, suite, fingerprint, records });
writeTopLevelIndex(suites);
}
const aggregate = writeSuiteReports({ suiteDirectory, suite, fingerprint, records });
writeTopLevelIndex(suites);
writeJsonAtomic(runPath, {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
pid: process.pid,
processStartedAt: runStartedAt,
completedAt: new Date().toISOString(),
command: process.argv,
suiteId: suite.id,
suiteFingerprint: fingerprint,
status: aggregate.gatePassed ? "passed" : "completed-with-failures",
execution: aggregate.execution
});
process.stdout.write(`${JSON.stringify(aggregate, null, 2)}\n`);
if (!selectedCaseId && !aggregate.gatePassed) {
process.exitCode = 1;
}