190 lines
6.4 KiB
JavaScript
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();
|