174 lines
5.8 KiB
JavaScript
174 lines
5.8 KiB
JavaScript
import { performance } from "node:perf_hooks";
|
|
import { spawn } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const defaultSuites = [
|
|
"additional-generation-unit",
|
|
"additional-generation-coverage-worker",
|
|
"core",
|
|
"terrain",
|
|
"terrain-name",
|
|
"admin",
|
|
"patch",
|
|
"patch-large",
|
|
"determinism-114514",
|
|
"determinism-12345",
|
|
"determinism-54321",
|
|
"determinism-777",
|
|
"determinism-999",
|
|
];
|
|
const requestedSuites = String(process.env.TEST_SUITES || "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
const suites = requestedSuites.length ? requestedSuites : defaultSuites;
|
|
const concurrency = Math.max(1, Math.min(2, Number(process.env.TEST_CONCURRENCY) || 1));
|
|
// Full-map shards can briefly peak at several hundred MB. CI may opt into a
|
|
// handoff delay when its runtime needs extra time to reclaim a completed child.
|
|
const suiteCooldownMs = Math.max(0, Number(process.env.TEST_SUITE_COOLDOWN_MS ?? 0));
|
|
const suiteTimeoutMs = {
|
|
"additional-generation-unit": 180_000,
|
|
"additional-generation-coverage-worker": 120_000,
|
|
core: 360_000,
|
|
terrain: 600_000,
|
|
"terrain-name": 240_000,
|
|
admin: 300_000,
|
|
patch: 300_000,
|
|
"patch-large": 600_000,
|
|
};
|
|
const defaultTimeoutMs = 240_000;
|
|
const maxOutputBytes = 32 * 1024 * 1024;
|
|
const cwd = fileURLToPath(new URL(".", import.meta.url));
|
|
const testFile = fileURLToPath(new URL("./test.js", import.meta.url));
|
|
const additionalUnitFile = fileURLToPath(new URL("./additional-generation-unit.mjs", import.meta.url));
|
|
const additionalCoverageWorkerFile = fileURLToPath(new URL("./additional-generation-coverage-worker.mjs", import.meta.url));
|
|
|
|
function runSuite(suite) {
|
|
return new Promise((resolve) => {
|
|
const timeoutMs = suiteTimeoutMs[suite] || defaultTimeoutMs;
|
|
const started = performance.now();
|
|
console.error(`[test-all] start ${suite}`);
|
|
const standaloneFile = suite === "additional-generation-unit"
|
|
? additionalUnitFile
|
|
: suite === "additional-generation-coverage-worker"
|
|
? additionalCoverageWorkerFile
|
|
: null;
|
|
const commandFile = standaloneFile || testFile;
|
|
const commandArgs = standaloneFile ? [commandFile] : [commandFile, `--suite=${suite}`];
|
|
const child = spawn(process.execPath, commandArgs, {
|
|
cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let outputOverflow = false;
|
|
let timedOut = false;
|
|
let forceKillTimer = null;
|
|
|
|
const append = (current, chunk) => {
|
|
if (outputOverflow) return current;
|
|
const next = current + chunk.toString("utf8");
|
|
if (Buffer.byteLength(next, "utf8") > maxOutputBytes) {
|
|
outputOverflow = true;
|
|
child.kill("SIGTERM");
|
|
return current;
|
|
}
|
|
return next;
|
|
};
|
|
|
|
child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
|
|
child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill("SIGTERM");
|
|
forceKillTimer = setTimeout(() => child.kill("SIGKILL"), 2_000);
|
|
forceKillTimer.unref?.();
|
|
}, timeoutMs);
|
|
timeout.unref?.();
|
|
|
|
child.on("error", (error) => {
|
|
clearTimeout(timeout);
|
|
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
const seconds = Math.round((performance.now() - started) / 10) / 100;
|
|
console.error(`[test-all] error ${suite}: ${error.message}`);
|
|
resolve({
|
|
suite,
|
|
seconds,
|
|
fullMapGenerations: null,
|
|
failedAssertions: null,
|
|
status: null,
|
|
signal: null,
|
|
timedOut,
|
|
outputOverflow,
|
|
infrastructureError: error.message,
|
|
});
|
|
});
|
|
|
|
child.on("close", (status, signal) => {
|
|
clearTimeout(timeout);
|
|
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
const seconds = Math.round((performance.now() - started) / 10) / 100;
|
|
const output = `${stdout}\n${stderr}`;
|
|
const ng = (output.match(/^NG:/gm) || []).length;
|
|
const info = output.match(/INFO: suite=([^;]+); fullMapGenerations=(\d+); elapsedMs=(\d+)/);
|
|
const infrastructureError = outputOverflow
|
|
? `Output exceeded ${maxOutputBytes} bytes`
|
|
: null;
|
|
console.error(
|
|
`[test-all] end ${suite}: status=${status} signal=${signal || "none"} ` +
|
|
`failures=${ng} seconds=${seconds}`,
|
|
);
|
|
resolve({
|
|
suite,
|
|
seconds,
|
|
fullMapGenerations: info ? Number(info[2]) : null,
|
|
elapsedMsReported: info ? Number(info[3]) : null,
|
|
failedAssertions: ng,
|
|
status,
|
|
signal: signal || null,
|
|
timedOut,
|
|
outputOverflow,
|
|
infrastructureError,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
const started = performance.now();
|
|
const queue = [...suites];
|
|
const completed = [];
|
|
|
|
async function worker() {
|
|
while (queue.length > 0) {
|
|
const suite = queue.shift();
|
|
if (!suite) return;
|
|
completed.push(await runSuite(suite));
|
|
if (suiteCooldownMs > 0 && queue.length > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, suiteCooldownMs));
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
completed.sort((a, b) => suites.indexOf(a.suite) - suites.indexOf(b.suite));
|
|
|
|
const wallSeconds = Math.round((performance.now() - started) / 10) / 100;
|
|
const failures = completed.reduce(
|
|
(sum, row) => sum + (Number.isFinite(row.failedAssertions) ? row.failedAssertions : 0),
|
|
0,
|
|
);
|
|
const infrastructureFailure = completed.some(
|
|
(row) => row.status !== 0 || row.signal || row.timedOut || row.outputOverflow || row.infrastructureError,
|
|
);
|
|
const withinSuiteBudgets = completed.every((row) => !row.timedOut);
|
|
|
|
const result = {
|
|
withinSuiteBudgets,
|
|
wallSeconds,
|
|
concurrency,
|
|
suiteCooldownMs,
|
|
failures,
|
|
infrastructureFailure,
|
|
suites: completed,
|
|
};
|
|
console.log(JSON.stringify(result, null, 2));
|
|
if (!withinSuiteBudgets || infrastructureFailure || failures > 0) process.exitCode = 1;
|