| 1 | import { spawn } from "node:child_process"; |
| 2 | import { createRequire } from "node:module"; |
| 3 | import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; |
| 5 | import path from "node:path"; |
| 6 | import { fileURLToPath } from "node:url"; |
| 7 | import { discoveredTests, testPlan } from "./ci-test-plan.mjs"; |
| 8 | |
| 9 | process.chdir(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")); |
| 10 | const scripts = JSON.parse(readFileSync("package.json", "utf8")).scripts; |
| 11 | const plan = testPlan(scripts, discoveredTests()); |
| 12 | if (process.argv.includes("--list")) { |
| 13 | console.log(JSON.stringify(plan, null, 2)); |
| 14 | } else { |
| 15 | const concurrency = Number(process.env.REASONIX_TEST_CONCURRENCY ?? 2); |
| 16 | if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error("test concurrency must be 1 to 4"); |
| 17 | const require = createRequire(import.meta.url); |
| 18 | const commands = { tsx: require.resolve("tsx/cli"), tsc: require.resolve("typescript/bin/tsc") }; |
| 19 | const logs = mkdtempSync(path.join(tmpdir(), "reasonix-ci-tests-")); |
| 20 | let failed = false; |
| 21 | let completed = 0; |
| 22 | const children = new Set(); |
| 23 | for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => { |
| 24 | failed = true; |
| 25 | for (const child of children) child.kill(signal); |
| 26 | process.exitCode = 1; |
| 27 | }); |
| 28 | async function run(entry) { |
| 29 | const started = performance.now(); |
| 30 | const log = path.join(logs, `${plan.indexOf(entry)}.log`); |
| 31 | const fd = openSync(log, "w"); |
| 32 | const [command, ...args] = entry.args; |
| 33 | const result = await new Promise(resolve => { |
| 34 | const child = spawn(process.execPath, command === "node" ? args : [commands[command], ...args], { |
| 35 | stdio: ["ignore", fd, fd], timeout: 10 * 60 * 1000, |
| 36 | env: { ...process.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }, |
| 37 | }); |
| 38 | children.add(child); |
| 39 | child.once("error", error => resolve({ error })); |
| 40 | child.once("close", (code, signal) => { children.delete(child); resolve({ code, signal }); }); |
| 41 | }); |
| 42 | closeSync(fd); |
| 43 | completed++; |
| 44 | const passed = result.code === 0 && !result.error; |
| 45 | console.log(`${passed ? "PASS" : "FAIL"} ${entry.key} (${Math.round(performance.now() - started)}ms)`); |
| 46 | if (!passed) { |
| 47 | failed = true; |
| 48 | console.error(result.error ?? result.signal ?? `exit ${result.code}`); |
| 49 | console.error(readFileSync(log, "utf8")); |
| 50 | } |
| 51 | } |
| 52 | try { |
| 53 | const queue = plan.filter(entry => !entry.serial); |
| 54 | console.log(`CI: ${plan.length} unique commands; concurrency=${concurrency}; performance benchmark runs alone`); |
| 55 | await Promise.all(Array.from({ length: concurrency }, async () => { |
| 56 | while (queue.length && !failed) await run(queue.shift()); |
| 57 | })); |
| 58 | for (const entry of plan.filter(entry => entry.serial)) if (!failed) await run(entry); |
| 59 | } finally { |
| 60 | rmSync(logs, { recursive: true, force: true }); |
| 61 | } |
| 62 | console.log(`CI: ${completed}/${plan.length} commands completed`); |
| 63 | if (failed || completed !== plan.length) process.exitCode = 1; |
| 64 | } |
| 65 |