Files
Battle-for-Tismo/tests/run_tests.js

190 lines
6.4 KiB
JavaScript

// Test runner. Every `tests/*_test.js` file is discovered automatically and run
// as a suite. By default the files are farmed out across a pool of worker
// processes, so the whole run is bounded by the slowest file rather than their
// sum. A single file can be run in-process for debugging.
//
// node tests/run_tests.js # all files, parallel
// node tests/run_tests.js --jobs 4 # cap the worker pool
// node tests/run_tests.js --file map_view_test.js
//
// In worker mode (`--file`) the file's exported TestCase classes are run and a
// single machine-readable result line is printed for the parent to collect.
import { readdirSync } from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { cpus } from "node:os";
const HERE = dirname(fileURLToPath(import.meta.url));
const SELF = fileURLToPath(import.meta.url);
const RESULT_MARKER = "__TEST_RESULT__";
// One worker per core, capped so a huge build machine is not flooded with
// jsdom workers. `--jobs N` still overrides this.
const MAX_JOBS = Math.min(16, cpus().length);
function flagValue(args, flag) {
const inline = args.find((arg) => arg.startsWith(`${flag}=`));
if (inline) return inline.slice(flag.length + 1);
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : null;
}
// Every test file in this directory, in a stable order.
function discoverFiles() {
return readdirSync(HERE)
.filter((name) => name.endsWith("_test.js"))
.sort();
}
// Every `test_*` method on a prototype chain, sorted. Walking the chain lets a
// suite share an inherited fixture base that declares no tests of its own;
// without that, those test methods would not be discovered on the subclass.
function testMethods(prototype) {
const names = new Set();
for (let proto = prototype; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
for (const name of Object.getOwnPropertyNames(proto)) {
if (name.startsWith("test_") && name !== "constructor") names.add(name);
}
}
return [...names].sort();
}
// The TestCase classes a module exports, with their sorted test methods. Using
// the exports rather than a hand-written list means a new test file is picked
// up with no runner edit.
function testClasses(module) {
const classes = [];
for (const value of Object.values(module)) {
if (typeof value !== "function" || !value.prototype) continue;
const methods = testMethods(value.prototype);
if (methods.length > 0) classes.push({ ctor: value, methods });
}
return classes;
}
async function runFile(file) {
const started = Date.now();
const module = await import(new URL(file, import.meta.url));
let passed = 0;
const failures = [];
for (const { ctor, methods } of testClasses(module)) {
for (const method of methods) {
const instance = new ctor();
try {
await instance[method]();
passed += 1;
} catch (error) {
failures.push(`${ctor.name}::${method}: ${error.message}`);
}
}
}
return { file, passed, failures, ms: Date.now() - started };
}
// Worker mode: run one file, then exit. When spawned by the parent it prints a
// machine-readable marker; run by hand it prints the usual summary. The write
// callback guarantees output reaches the parent before the process ends.
async function runWorker(file) {
const result = await runFile(file);
const code = result.failures.length === 0 ? 0 : 1;
if (process.env.TISMO_WORKER === "1") {
const line = `\n${RESULT_MARKER}${JSON.stringify(result)}\n`;
process.stdout.write(line, () => process.exit(code));
return;
}
console.log("");
if (result.failures.length === 0) console.log(`PASS: ${result.passed} test(s) passed.`);
else {
console.log(`FAIL: ${result.passed} passed, ${result.failures.length} failed.`);
for (const failure of result.failures) console.log(` - ${failure}`);
}
process.exit(code);
}
function runChild(file) {
return new Promise((resolve) => {
const started = Date.now();
const child = spawn(process.execPath, [SELF, "--file", file], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, TISMO_WORKER: "1" },
});
let out = "";
let err = "";
child.stdout.on("data", (chunk) => (out += chunk));
child.stderr.on("data", (chunk) => (err += chunk));
child.on("close", (code) => {
const index = out.lastIndexOf(RESULT_MARKER);
if (index >= 0) {
const line = out.slice(index + RESULT_MARKER.length).split("\n")[0];
try {
resolve(JSON.parse(line));
return;
} catch {
// fall through to the failure record below
}
}
resolve({
file,
passed: 0,
failures: [`${file}: worker produced no result (exit ${code}) ${err.trim()}`],
ms: Date.now() - started,
});
});
});
}
// Runs `worker` over `items`, never more than `jobs` at a time. Results keep
// their input order so the summary is deterministic.
async function pool(items, jobs, worker) {
const results = new Array(items.length);
let next = 0;
const run = async () => {
while (next < items.length) {
const index = next;
next += 1;
results[index] = await worker(items[index]);
}
};
await Promise.all(Array.from({ length: Math.min(jobs, items.length) }, run));
return results;
}
async function main() {
const args = process.argv.slice(2);
const fileArg = flagValue(args, "--file");
if (fileArg) {
await runWorker(fileArg);
return;
}
const requested = Number(flagValue(args, "--jobs")) || cpus().length;
const jobs = Math.max(1, Math.min(requested, MAX_JOBS));
const files = discoverFiles();
const results = await pool(files, jobs, runChild);
let passed = 0;
const failures = [];
for (const result of results) {
passed += result.passed;
failures.push(...result.failures);
}
console.log("");
for (const result of results.slice().sort((a, b) => b.ms - a.ms)) {
const seconds = (result.ms / 1000).toFixed(1).padStart(5);
const status = result.failures.length === 0 ? "ok " : "FAIL";
console.log(`${status} ${seconds}s ${result.file}`);
}
console.log("");
if (failures.length === 0) {
console.log(`PASS: ${passed} test(s) passed.`);
process.exit(0);
}
console.log(`FAIL: ${passed} passed, ${failures.length} failed.`);
for (const failure of failures) console.log(` - ${failure}`);
process.exit(1);
}
main();