文件
hyb-oa/scripts/qa/full-local-diagnostic.mjs
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

259 行
14 KiB
JavaScript

#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { closeSync, existsSync, lstatSync, readlinkSync, realpathSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const stagePlan = [
{ id: "inputs", dependencies: [] },
{ id: "tools", dependencies: [] },
{ id: "server-unit", dependencies: ["tools"] },
{ id: "typecheck", dependencies: [] },
{ id: "client-build", dependencies: [] },
{ id: "workflow-actions", dependencies: ["tools", "client-build"] },
{ id: "server-regressions", dependencies: ["tools", "client-build"] },
{ id: "browser", dependencies: ["inputs", "tools", "client-build"] },
];
function readJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
function atomicJson(file, value) {
writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
renameSync(`${file}.tmp`, file);
}
function currentEnvironment(requireGuest = false) {
const result = spawnSync("node", [path.join(root, "scripts/qa/full-local-environment.mjs"), requireGuest ? "report-guest" : "report"], { encoding: "utf8", timeout: 30000 });
if (result.status !== 0) throw new Error("environment_global_precondition_failed");
return JSON.parse(result.stdout);
}
export function createDiagnosticScratch() {
return mkdtempSync(path.join(os.tmpdir(), "hyb-qa-inventory-"));
}
export function sourceSnapshot(directory = root) {
directory = realpathSync(directory);
const git = (args) => {
const result = spawnSync("git", args, { cwd: directory, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (result.status !== 0) throw new Error("diagnostic_source_unavailable");
return result.stdout;
};
const hash = value => createHash("sha256").update(value).digest("hex");
const snapshotFile = file => {
const target = path.join(directory, file);
let stat;
try { stat = lstatSync(target); } catch (error) { if (error.code === "ENOENT") return { file, kind: "missing", sha256: null }; throw error; }
if (realpathSync(path.dirname(target)) !== path.dirname(target)) return { file, kind: "unsupported", sha256: null };
const kind = stat.isSymbolicLink() ? "symlink" : stat.isFile() ? "file" : "unsupported";
return { file, kind, mode: stat.mode & 0o777, sha256: kind === "symlink" ? hash(readlinkSync(target)) : kind === "file" ? hash(readFileSync(target)) : null };
};
const files = git(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "scripts/qa", "server", "client", ".trellis/spec"])
.split("\0").filter(Boolean).sort().map(file => ({ file, sha256: snapshotFile(file).sha256 }));
const trackedFiles = [...new Set(git(["ls-files", "--cached", "-z"]).split("\0").filter(Boolean))].sort().map(snapshotFile);
const porcelainV1Z = git(["status", "--porcelain=v1", "--untracked-files=all", "-z"]);
const indexEntriesZ = git(["ls-files", "--stage", "-z"]);
const indexFlagsZ = git(["ls-files", "-v", "-z"]);
const repository = {
porcelainV1Z, statusShort: git(["status", "--short", "--untracked-files=all"]),
trackedFiles, trackedDigest: hash(JSON.stringify(trackedFiles)),
indexEntriesZ, indexFlagsZ, indexDigest: hash(JSON.stringify([indexEntriesZ, indexFlagsZ])),
trackedCoverageComplete: trackedFiles.every(entry => entry.kind !== "unsupported"),
};
return {
commit: git(["rev-parse", "HEAD"]).trim(),
tree: git(["rev-parse", "HEAD^{tree}"]).trim(),
dirty: porcelainV1Z.length !== 0,
repository,
files,
digest: createHash("sha256").update(JSON.stringify(files)).digest("hex"),
};
}
export function compareSnapshots(before, after) {
const identityUnchanged = Boolean(before && after && before.commit === after.commit && before.tree === after.tree);
const sourceUnchanged = identityUnchanged && before.digest === after.digest;
const repositoryComparable = [before, after].every(snapshot => snapshot?.repository?.trackedCoverageComplete && /^[a-f0-9]{64}$/.test(snapshot.repository.trackedDigest ?? "") && /^[a-f0-9]{64}$/.test(snapshot.repository.indexDigest ?? "") && typeof snapshot.repository.porcelainV1Z === "string");
const repositoryUnchanged = Boolean(identityUnchanged && repositoryComparable && before.repository.trackedDigest === after.repository.trackedDigest && before.repository.indexDigest === after.repository.indexDigest && before.repository.porcelainV1Z === after.repository.porcelainV1Z);
const candidateIntegrityStatus = !repositoryComparable ? "UNKNOWN" : sourceUnchanged && repositoryUnchanged ? "UNCHANGED" : "CHANGED";
return { sourceUnchanged, repositoryComparable, repositoryUnchanged, candidateIntegrityStatus, cleanAtStart: before?.dirty === false, cleanAtEnd: after?.dirty === false };
}
export function browserInventory(discovery, execution) {
const actual = new Map();
const key = (entry) => JSON.stringify([entry.id, entry.projectName ?? "", entry.repeatEachIndex ?? 0]);
const issues = [];
function visit(suites) {
for (const suite of suites ?? []) {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
const identity = { id: spec.id, projectName: test.projectName, repeatEachIndex: test.repeatEachIndex };
if (actual.has(key(identity))) issues.push("duplicate_case");
actual.set(key(identity), {
reportedStatus: test.status,
expectedStatus: test.expectedStatus,
results: (test.results ?? []).map(result => ({ status: result.status, retry: result.retry, duration: result.duration })),
});
}
}
visit(suite.suites);
}
}
visit(execution?.suites);
const cases = (discovery?.cases ?? []).map(entry => {
const observed = actual.get(key(entry));
actual.delete(key(entry));
return { ...entry, status: observed?.results.at(-1)?.status ?? "NOT_RUN", ...observed };
});
if (actual.size) issues.push("unknown_cases");
return { cases, issues, executionErrors: execution?.errors?.length ?? 0, reportPresent: Boolean(execution) };
}
async function main() {
const approval = {};
for (const name of ["REASON", "OPERATOR", "APPROVAL_REF"]) {
const value = process.env[`QA_DIAGNOSTIC_${name}`]?.trim();
if (!value || value.length > 500) throw new Error(`diagnostic_${name.toLowerCase()}_required`);
approval[name.toLowerCase()] = value;
}
if ((process.env.QA_DB_HOST ?? "127.0.0.1") !== "127.0.0.1" || (process.env.QA_DB_CLIENT ?? "host") !== "host") throw new Error("diagnostic_requires_local_host_database");
if (process.env.QA_KEEP_DB === "1" || process.env.QA_SKIP_CLIENT_BUILD === "1") throw new Error("diagnostic_reuse_not_allowed");
for (const name of ["PLAYWRIGHT_MANUAL", "R70_TRIALRUN_ASSIST", "R70_TRIALRUN_DEEP_ASSIST", "PLAYWRIGHT_OCR_LIVE_BATCH"]) {
if (process.env[name] === "1") throw new Error("diagnostic_scope_override_not_allowed");
}
const runId = `${Date.now()}_${randomBytes(5).toString("hex")}`;
const reportDir = path.resolve(process.env.QA_DIAGNOSTIC_REPORT_DIR ?? process.env.QA_FULL_REPORT_DIR ?? path.join(root, "artifacts/qa-flow/full-local-diagnostic", runId));
if (existsSync(reportDir) && readdirSync(reportDir).length) throw new Error("diagnostic_report_directory_not_empty");
mkdirSync(reportDir, { recursive: true, mode: 0o700 });
const scratch = createDiagnosticScratch();
const report = {
schemaVersion: 1, kind: "FULL_LOCAL_DIAGNOSTIC_NOT_RELEASE_RECEIPT", profile: "full-local-diagnostic", status: "PARTIAL",
runId, approval, startedAt: new Date().toISOString(), endedAt: null,
nodeVersion: process.version, ci: process.env.CI ?? null,
stages: stagePlan.map(stage => ({ ...stage, status: "NOT_RUN" })),
limits: ["No release eligibility, even with zero observed failures.", "Nested fail-fast and serial dependencies remain observable coverage gaps.", "server-regressions includes a separate workflow-actions invocation.", "Diagnostic duration is not normal full-local green-path duration."],
};
let interrupted = null;
let activeChild = null;
const save = () => atomicJson(path.join(reportDir, "diagnostic-results.json"), report);
const interrupt = (signal) => {
interrupted = signal;
if (activeChild?.pid) {
try { process.kill(-activeChild.pid, signal); } catch { report.signalDeliveryFailed = true; }
}
};
const onInt = () => interrupt("SIGINT");
const onTerm = () => interrupt("SIGTERM");
process.on("SIGINT", onInt);
process.on("SIGTERM", onTerm);
try {
report.environmentBefore = currentEnvironment();
report.sourceBefore = sourceSnapshot();
save();
for (const stage of report.stages) {
const blockedBy = stage.dependencies.filter(id => report.stages.find(item => item.id === id).status !== "PASS");
if (interrupted || blockedBy.length) {
stage.reason = interrupted ? "interrupted" : "prerequisite_failed";
stage.blockedBy = blockedBy;
save();
continue;
}
stage.environmentBefore = currentEnvironment();
const stageDir = path.join(reportDir, stage.id);
const temporary = path.join(scratch, stage.id);
mkdirSync(stageDir);
mkdirSync(temporary);
mkdirSync(path.join(temporary, "storage"));
const environment = {
...process.env,
QA_DB_HOST: "127.0.0.1", QA_DB_CLIENT: "host", QA_KEEP_DB: "0", QA_SKIP_CLIENT_BUILD: "0",
QA_DB_NAME: `hyb_oa_diag_${runId}_${stage.id.replaceAll("-", "_")}`,
QA_DIAGNOSTIC_STAGE: stage.id, QA_DIAGNOSTIC_STAGE_DIR: stageDir,
QA_FULL_REPORT_DIR: path.join(reportDir, "full-local-report"),
QA_SERVER_LOG: path.join(stageDir, "server.log"), QA_SERVER_PID_FILE: path.join(stageDir, "server.pid"),
TMPDIR: temporary, STORAGE_DIR: path.join(temporary, "storage"), STORAGE_DRIVER: "local",
QA_HOST: "127.0.0.1", JOBS_ENABLED: "false", E2E_MODE: "local",
};
if (stage.id === "browser") {
const fixture = readJson(path.join(reportDir, "inputs/pwa-fixture.json"));
if (!fixture.root.startsWith(`${path.join(scratch, "inputs")}${path.sep}`)) throw new Error("diagnostic_fixture_ownership_invalid");
Object.assign(environment, { QA_DIAGNOSTIC_PWA_ROOT: fixture.root, PWA_BUILD_A_DIR: fixture.buildA, PWA_BUILD_B_DIR: fixture.buildB, PWA_BUILD_A_SHA: fixture.shaA, PWA_BUILD_B_SHA: fixture.shaB });
}
stage.command = ["bash", "scripts/qa/run-web-gates.sh", "__full-local-diagnostic-stage", stage.id];
stage.database = environment.QA_DB_NAME;
stage.startedAt = new Date().toISOString();
stage.status = "RUNNING";
save();
console.log(`[diagnostic] ${stage.id} started`);
const descriptor = openSync(path.join(stageDir, "stage.log"), "w", 0o600);
const outcome = await new Promise(resolve => {
activeChild = spawn("bash", stage.command.slice(1), { cwd: root, env: environment, stdio: ["ignore", descriptor, descriptor], detached: true });
activeChild.once("error", () => resolve({ code: 1, signal: null, spawnFailed: true }));
activeChild.once("close", (code, signal) => resolve({ code, signal }));
});
activeChild = null;
closeSync(descriptor);
stage.endedAt = new Date().toISOString();
stage.durationMs = Date.parse(stage.endedAt) - Date.parse(stage.startedAt);
stage.exitCode = outcome.code;
stage.signal = outcome.signal;
const completion = path.join(stageDir, "outcome.json");
stage.completion = existsSync(completion) ? readJson(completion) : null;
stage.status = outcome.code === 0 && stage.completion?.cleanupExit === 0 ? "PASS" : "FAIL";
stage.failureKind = stage.status === "PASS" ? null : stage.completion?.cleanupExit ? "cleanup" : stage.completion?.phase === "test" ? "test" : "environment";
stage.environmentAfter = currentEnvironment(stage.id === "browser");
save();
console.log(`[diagnostic] ${stage.id} ${stage.status} exit=${outcome.code}`);
}
} catch (error) {
report.runnerError = error.message;
} finally {
report.interrupted = interrupted;
report.environmentQualified = Boolean(report.stages.find(stage => stage.id === "browser")?.environmentAfter?.bootstrapObservation?.valid) && !report.runnerError;
for (const stage of report.stages) {
if (stage.status === "NOT_RUN" && !stage.reason) stage.reason = interrupted ? "interrupted" : "runner_error";
}
const discoveryFile = path.join(reportDir, "full-local-report/discovery-coverage.json");
const browserFile = path.join(reportDir, "full-local-report/playwright-results.json");
let discovery = null;
try {
discovery = existsSync(discoveryFile) ? readJson(discoveryFile) : null;
const planFile = path.join(reportDir, "full-local-report/coverage-plan.json");
if (existsSync(planFile)) {
const plan = readJson(planFile);
if (plan.status !== "PASS") throw new Error("invalid_layered_plan");
discovery = { ...discovery, cases: plan.localCases };
report.coverageObligations = plan.obligations;
const resultFile = path.join(reportDir, "full-local-report/local-coverage-result.json");
report.localCoverage = existsSync(resultFile) ? readJson(resultFile) : { status: "NOT_RUN" };
}
report.browser = browserInventory(discovery, existsSync(browserFile) ? readJson(browserFile) : null);
} catch {
report.browser = browserInventory(discovery, null);
report.browser.issues.push("report_unreadable");
}
try { rmSync(scratch, { recursive: true }); report.scratchCleanup = "PASS"; } catch { report.scratchCleanup = "FAIL"; }
try {
report.sourceAfter = sourceSnapshot();
Object.assign(report, compareSnapshots(report.sourceBefore, report.sourceAfter));
} catch { report.repositorySnapshotError = "post_run_snapshot_unavailable"; report.repositoryUnchanged = false; report.candidateIntegrityStatus = "UNKNOWN"; }
report.endedAt = new Date().toISOString();
report.durationMs = Date.parse(report.endedAt) - Date.parse(report.startedAt);
report.exitCode = 2;
save();
process.off("SIGINT", onInt);
process.off("SIGTERM", onTerm);
}
console.log("[diagnostic] PARTIAL — no release eligibility");
process.exitCode = 2;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch(error => { console.error(`[diagnostic] ${error.message}`); process.exitCode = 1; });
}