import { performance } from "node:perf_hooks"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; const cwd = fileURLToPath(new URL(".", import.meta.url)); const testFile = fileURLToPath(new URL("./test.js", import.meta.url)); const suiteDefinitions = [ { name: "additional-generation-unit", file: "./additional-generation-unit.mjs", timeoutMs: 180_000, canonical: true }, { name: "additional-generation-coverage-worker", file: "./additional-generation-coverage-worker.mjs", timeoutMs: 240_000, canonical: true }, { name: "r10-exact-production-worker", file: "./r10-exact-production-worker.mjs", timeoutMs: 180_000, canonical: true }, { name: "r11-selection-native-production", file: "./r11-selection-native-production.mjs", timeoutMs: 180_000, canonical: true }, { name: "additional-generation-max-worker", file: "./additional-generation-max-worker.mjs", timeoutMs: 240_000, release: true }, { name: "patch-worker-cancel", file: "./patch-worker-cancel.mjs", timeoutMs: 180_000, release: true }, { name: "patch-worker-mirror-sync", file: "./patch-worker-mirror-sync.mjs", timeoutMs: 180_000, release: true }, { name: "r11.4-literal-initial-overscan-worker", file: "./r11.4-literal-initial-overscan-worker.mjs", timeoutMs: 300_000, release: true }, { name: "r11.4-transport-demography-overscan", file: "./r11.4-transport-demography-overscan.mjs", timeoutMs: 300_000, release: true }, { name: "r11.5-visible-quality-finalizer", file: "./r11.5-visible-quality-finalizer.mjs", timeoutMs: 600_000, release: true }, { name: "r11.6-large-bestof-quality", file: "./r11.6-large-bestof-quality.mjs", timeoutMs: 600_000, release: true }, { name: "r11.7-terrain-routed-transport-density", file: "./r11.7-terrain-routed-transport-density.mjs", timeoutMs: 300_000, release: true }, { name: "r11.8-terrain-topology-tooltip", file: "./r11.8-terrain-topology-tooltip.mjs", timeoutMs: 600_000, release: true }, { name: "additional-generation-browser", file: "./run-additional-generation-browser.mjs", timeoutMs: 600_000, browser: true }, { name: "core", timeoutMs: 360_000, canonical: true }, { name: "terrain", timeoutMs: 600_000, canonical: true }, { name: "terrain-name", timeoutMs: 240_000, canonical: true }, { name: "admin", timeoutMs: 300_000, canonical: true }, { name: "patch", timeoutMs: 300_000, canonical: true }, { name: "patch-large", timeoutMs: 600_000, canonical: true }, { name: "determinism-114514", timeoutMs: 240_000, canonical: true }, { name: "determinism-12345", timeoutMs: 240_000, canonical: true }, { name: "determinism-54321", timeoutMs: 240_000, canonical: true }, { name: "determinism-777", timeoutMs: 240_000, canonical: true }, { name: "determinism", timeoutMs: 240_000 }, { name: "all", timeoutMs: 900_000 }, ]; const suiteRegistry = new Map(suiteDefinitions.map((definition) => [definition.name, definition])); const suiteGroups = new Map([ ["default", suiteDefinitions.filter((definition) => definition.canonical).map((definition) => definition.name)], ["release", suiteDefinitions.filter((definition) => definition.canonical || definition.release).map((definition) => definition.name)], ["browser", suiteDefinitions.filter((definition) => definition.browser).map((definition) => definition.name)], ]); const defaultTimeoutMs = 240_000; function resolveSuite(name) { const registered = suiteRegistry.get(name); if (registered) return registered; if (/^determinism-(?:0|[1-9]\d*)$/.test(name)) return { name, timeoutMs: defaultTimeoutMs }; return null; } const requestedSuites = String(process.env.TEST_SUITES || "").split(",").map((value) => value.trim()).filter(Boolean); const requestedGroup = String(process.env.TEST_GROUP || "default").trim(); if (!suiteGroups.has(requestedGroup)) throw new Error(`Unknown test group: ${requestedGroup}`); const suites = requestedSuites.length ? requestedSuites : suiteGroups.get(requestedGroup); const unknownSuites = suites.filter((suite) => !resolveSuite(suite)); if (unknownSuites.length) throw new Error(`Unknown test suite${unknownSuites.length === 1 ? "" : "s"}: ${unknownSuites.join(", ")}`); 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 maxOutputBytes = 32 * 1024 * 1024; function runSuite(suite) { return new Promise((resolve) => { const descriptor = resolveSuite(suite); const timeoutMs = descriptor.timeoutMs || defaultTimeoutMs; const started = performance.now(); console.error(`[test-all] start ${suite}`); const standaloneFile = descriptor.file ? fileURLToPath(new URL(descriptor.file, import.meta.url)) : null; const commandFile = standaloneFile || testFile; const commandArgs = standaloneFile ? [commandFile] : [commandFile, `--suite=${suite}`]; const child = spawn(process.execPath, commandArgs, { cwd, stdio: ["ignore", "pipe", "pipe"], }); const stdoutChunks = []; const stderrChunks = []; let outputBytes = 0; let outputOverflow = false; let timedOut = false; let forceKillTimer = null; let resolved = false; const finish = (payload) => { if (resolved) return; resolved = true; resolve(payload); }; const append = (target, chunk) => { if (outputOverflow) return; const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); if (outputBytes + buffer.byteLength > maxOutputBytes) { outputOverflow = true; child.kill("SIGTERM"); return; } outputBytes += buffer.byteLength; target.push(buffer); }; child.stdout.on("data", (chunk) => append(stdoutChunks, chunk)); child.stderr.on("data", (chunk) => append(stderrChunks, 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}`); finish({ 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 stdout = Buffer.concat(stdoutChunks).toString("utf8"); const stderr = Buffer.concat(stderrChunks).toString("utf8"); 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; const testFailure = status !== 0 && !signal && !timedOut && !outputOverflow && !infrastructureError; const failedAssertions = ng > 0 ? ng : testFailure ? 1 : 0; console.error( `[test-all] end ${suite}: status=${status} signal=${signal || "none"} ` + `failures=${failedAssertions} seconds=${seconds}`, ); if (testFailure || signal || timedOut || outputOverflow || infrastructureError) { const tail = output.trim().slice(-16_000); if (tail) console.error(`[test-all] output ${suite}:\n${tail}`); } finish({ suite, seconds, fullMapGenerations: info ? Number(info[2]) : null, elapsedMsReported: info ? Number(info[3]) : null, failedAssertions, testFailure, 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.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;