// 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__"; const MAX_JOBS = 8; 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(); } // 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 = Object.getOwnPropertyNames(value.prototype) .filter((name) => name.startsWith("test_") && name !== "constructor") .sort(); 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();