250 行
14 KiB
JavaScript
250 行
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;
|
|
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; });
|
|
}
|