#!/usr/bin/env bun
/**
* The test runner: `bun run test`.
*
* It runs every test file in its own bun process, several files at a time, and it
* is written in TypeScript rather than shell so that the one command a contributor
* is told to run behaves the same on Linux, macOS and Windows. The two bash scripts
* it replaces could not: `tests/run-core.sh` used `mapfile`, a bash 4 builtin, and
* macOS ships bash 3.2, so the documented gate never ran there at all.
*
* Why a process per file rather than `bun test
`: see the docblock in
* `tests/runner/execute.ts`.
*
* bun tests/run-tests.ts every test file
* bun tests/run-tests.ts tests/api one layer
* bun tests/run-tests.ts tests/api/db.test.ts one file
* bun tests/run-tests.ts --jobs=4 bound the concurrency
* bun tests/run-tests.ts --list what would run
* bun tests/run-tests.ts --coverage --merge-into=coverage/lcov.info
* bun tests/run-tests.ts tests/unit -- --bail pass flags to bun test
*
* That last one needs both halves as written: a selector before the `--`, and the
* runner invoked directly. `bun run test -- --bail` does not work, because `bun run`
* consumes the first `--` itself, and neither does a `--` straight after the script
* path, for the same reason (measured on 1.4.2; the refusal in runner/options.ts says
* so).
*
* It exits 0 when every file passed, 1 when a file failed, and 2 when the runner could
* not do its job (a usage error, a selector that names nothing, a scratch directory it
* could not remove, a write that failed for the runner's own reason such as a full
* disk). A reader that goes away is NOT one of those: `bun run test | head -1` keeps
* the run's own 0 or 1, because turning it into 2 would hide the very distinction the
* 2 is for. Stopped by SIGINT, SIGTERM, SIGHUP or SIGBREAK, it stops scheduling, kills
* the files still running, removes its scratch directory and exits 128 + the signal's
* number: 130, 143, 129, and 149 for SIGBREAK on Windows (see tests/runner/signals.ts).
*/
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { availableParallelism, tmpdir } from "node:os";
import path from "node:path";
import { captureBounded } from "./runner/capture";
import { assertCoverageDirIsOurs, assertMergeTargetIsOurs } from "./runner/coverage";
import { COVERAGE_EXEMPT_FILES, selectTestFiles } from "./runner/discover";
import { coverageDirFor, type RunFile, runTestFiles, type SpawnOutcome } from "./runner/execute";
import { parseRunnerArgs, perTestTimeoutArgs, type RunnerOptions } from "./runner/options";
import { formatFileLine, formatSummary } from "./runner/report";
import { missingHelm, planRequirements, requiredCapabilities, systemHelmProbe } from "./runner/requirements";
import { exitCodeForSignal, STOP_SIGNALS, type StopSignal } from "./runner/signals";
const root = path.resolve(import.meta.dir, "..");
/** How long a child that was asked to stop is given before it is killed outright. */
const KILL_ESCALATION_MS = 5_000;
/**
* How long a signal's last line is given to reach its reader before the process ends
* anyway.
*
* A signal has to end the run promptly whatever the reader is doing. Measured on bun
* 1.4.2 with a consumer that had stopped reading: the handler's own write queued
* behind a full pipe (64 KiB on Linux), the process sat there for the whole stall and
* survived a second SIGINT, a SIGTERM and a SIGHUP, and when the consumer finally
* drained it exited 1 with the run's summary and "Interrupted (SIGINT)." after it. So
* the write is raced against this, and when it loses, the Interrupted line is lost:
* the reader that was not reading is the one that does not get it, and the exit code
* and the scratch-directory removal still say what happened. Three seconds is far more
* than a reader that is reading needs, even on a loaded 4-CPU CI runner.
*/
const SIGNAL_WRITE_GRACE_MS = 3_000;
const live = new Set();
/**
* The run's own temporary directory (the children's junit reports), removed on every
* way out: a normal end, an error (exit 2), and any of SIGINT, SIGTERM, SIGHUP and
* SIGBREAK, which end the run with 128 + the signal's number (130, 143, 129) or with
* exit 2 when the directory cannot be removed. The children themselves need no such
* care, because `--no-orphans` takes them down with this process.
*/
let runScratch: string | null = null;
/**
* Removes it, and answers with what went wrong rather than throwing.
*
* `force: true` only ignores a path that is not there; a directory that cannot be
* removed (no write permission on its parent, a Windows handle still open on a junit
* file) still throws. Every caller here is on its way out, two of them from a signal
* listener, where a throw is worse than useless: measured on bun 1.4.2, a throw from
* inside a SIGINT listener left the process RUNNING and the queue started the next
* file. So the failure comes back as a sentence for the caller to print before it
* exits 2, and is never swallowed.
*/
function removeRunScratch(): string | null {
if (runScratch === null) return null;
const directory = runScratch;
runScratch = null;
try {
rmSync(directory, { recursive: true, force: true });
return null;
} catch (error) {
return `The run's scratch directory ${directory} could not be removed: ${error instanceof Error ? error.message : String(error)}`;
}
}
/**
* True once the run has been stopped from outside (a signal, or an error that ends
* it): no further file is started, and the results of the children being killed are
* not printed as though they were the run's own verdict.
*/
let stopping = false;
/** True once the run's header has been written, so stdout has something to drain. */
let runStarted = false;
/** Stops scheduling and asks every running child to stop. */
function stopRun(): void {
stopping = true;
for (const child of live) child.kill("SIGTERM");
}
/**
* Writes, and resolves when the bytes have actually left this process.
*
* bun writes to a pipe asynchronously, and `process.exit` throws away whatever is
* still pending: measured on 1.4.2, a run whose stdout was a pipe (a CI log, `| tee`,
* a test driving the runner) lost about a third of a failing file's megabyte of
* output AND the whole summary with it, exit code intact. So every exit path writes
* its last line through here first. The callback of a write waits for the writes
* queued before it as well, which is what drains the output printed as files landed;
* an EMPTY write's callback does not (measured), so this is only ever called with
* text.
*
* What it covers is what THIS process writes. A child can still drop part of its own
* queued console output when it exits under load, with no runner in the picture at
* all (measured on 1.4.2, and filed as D96 in docs/BACKLOG.md), and no drain here can
* put that back.
*/
function written(stream: NodeJS.WriteStream, text: string): Promise {
return new Promise((resolve, reject) => {
stream.write(text, (error) => (error ? reject(error) : resolve()));
});
}
/**
* True when a write failed because the reader has gone rather than because this
* process could not write: `| head -1`, a CI log tailer that stopped, a closed
* terminal. Measured on bun 1.4.2, the write callback's error carries `code` "EPIPE"
* for those, and a real runner problem carries its own ("ENOSPC" for a full disk,
* measured against /dev/full), so the two are told apart by that and not by guesswork.
* The other two names are reasoned rather than measured: neither came up here, and
* both describe a descriptor that has gone rather than a run that could not be made.
*/
function readerHasGone(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED" || code === "EBADF";
}
/** Writes and waits, treating a reader that has gone as nothing to report. */
async function writtenOrReaderGone(stream: NodeJS.WriteStream, text: string): Promise {
try {
await written(stream, text);
} catch (error) {
if (!readerHasGone(error)) throw error;
}
}
/**
* The one way out: the last lines reach their reader, then the process ends.
*
* `graceMs` bounds the wait. Only the signal path passes it, because only the signal
* path has something more urgent than its own last line (see SIGNAL_WRITE_GRACE_MS);
* everywhere else the write is the reason the process is still alive.
*/
async function exitAfterWriting(
code: number,
text: { stdout?: string; stderr?: string },
graceMs?: number,
): Promise {
const writes: Promise[] = [];
if (text.stdout !== undefined) writes.push(written(process.stdout, text.stdout));
if (text.stderr !== undefined) writes.push(written(process.stderr, text.stderr));
// allSettled, not all: the other stream still has something to say. A scratch
// directory that could not be removed is explained on stderr while the run's last
// line goes to stdout, and Promise.all would abandon one the moment the other broke.
// Answered inside the promise rather than in a catch around the await, so the grace
// below cannot walk away from a rejection and leave an unhandled one behind.
const drained = Promise.allSettled(writes).then((results) =>
// The stream that refused the bytes is the stream that would have carried the
// explanation, so nothing more can be said there; what is left to say is said by
// the exit code. A reader that went away is not the runner failing, and answering
// 2 there would take the run's own verdict away from `bun run test | head -1`.
// Any other write error IS the runner's problem, which is what 2 is for.
results
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.every((result) => readerHasGone(result.reason))
? code
: 2,
);
process.exit(
graceMs === undefined ? await drained : await Promise.race([drained, Bun.sleep(graceMs).then(() => code)]),
);
}
/**
* Children inherit the environment, plus one decision: `FORCE_COLOR` when this
* runner is on a terminal. Each child's output is a pipe, so bun would drop its
* colour and the failure diffs are much harder to read without it. `NO_COLOR` wins
* over that, because it is the user's own word.
*
* Nothing else is set here. The environment tests run under is pinned by
* `tests/setup.ts`, which bunfig preloads into every child.
*/
function childEnvironment(): Record {
const wantsColour = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
return wantsColour ? { ...process.env, FORCE_COLOR: "1" } : { ...process.env };
}
function spawnTestFile(bunArgs: string[], junitDir: string): RunFile {
return async ({ file, index, coverageDir, timeoutMs }): Promise => {
const coverageArgs = coverageDir ? ["--coverage", "--coverage-reporter=lcov", `--coverage-dir=${coverageDir}`] : [];
// Every child writes a junit report, and it is the runner's only source of truth
// about what the file did: the counts, and the titles of the tests it skipped,
// which bun names nowhere else. Its console output is for a reader and decides
// nothing (see readTestReport). The file is small, even for a child that printed
// hundreds of megabytes, and the whole directory is removed when the run ends.
const junitPath = path.join(junitDir, `file-${index + 1}.xml`);
const command = [
process.execPath,
// Reap whatever the file spawned (helm, node, sh) if this child is killed:
// bun uses PR_SET_PDEATHSIG on Linux, EVFILT_PROC on macOS and a
// kill-on-close Job Object on Windows, so a timeout leaves nothing behind.
"--no-orphans",
"test",
// BEFORE the forwarded arguments, so a contributor's own `-- --timeout=...` still
// decides: bun takes the last of a repeated option, the same rule the reporter
// flags below rely on. Empty everywhere but Windows.
...perTestTimeoutArgs(process.platform),
...bunArgs,
// AFTER the user's arguments, because bun takes the last of a repeated option:
// a forwarded `-- --reporter-outfile=x` would otherwise send the report
// somewhere else and leave every file looking as though it wrote none.
"--reporter=junit",
`--reporter-outfile=${junitPath}`,
...coverageArgs,
// "./" matters: bun reads a bare relative path as a SUBSTRING FILTER over the
// whole tree, so `bun test tests/a/b.test.ts` also runs any other file whose
// path contains that string. With the prefix it is a path, on every platform.
`./${file}`,
];
const startedAt = Date.now();
const child = Bun.spawn(command, {
// Every child runs from the repository root: bunfig.toml's preload, the `@/`
// alias and the tests that read repository files all resolve from there.
cwd: root,
stdout: "pipe",
stderr: "pipe",
env: childEnvironment(),
});
live.add(child);
let timedOut = false;
const softKill = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeoutMs);
const hardKill = setTimeout(() => {
if (timedOut) child.kill("SIGKILL");
}, timeoutMs + KILL_ESCALATION_MS);
// bun writes its file header, failure diffs and per-file summary to stderr, and
// the tests' own console output to stdout, so both are captured. Each is bounded
// at both ends rather than read whole: see tests/runner/capture.ts.
const [stdout, stderr] = await Promise.all([
captureBounded(child.stdout as ReadableStream, { name: "stdout" }),
captureBounded(child.stderr as ReadableStream, { name: "stderr" }),
]);
const exitCode = await child.exited;
clearTimeout(softKill);
clearTimeout(hardKill);
live.delete(child);
return {
exitCode: child.signalCode ? null : exitCode,
signal: child.signalCode,
output: `${stderr}${stdout}`,
durationMs: Date.now() - startedAt,
timedOut,
junitReport: existsSync(junitPath) ? readFileSync(junitPath, "utf8") : null,
};
};
}
function mergeCoverage(options: RunnerOptions, files: string[]): void {
const reports = files
.map((file, index) =>
coverageDirFor(file, index, {
coverage: options.coverage,
coverageDir: options.coverageDir,
coverageExempt: COVERAGE_EXEMPT_FILES,
}),
)
.filter((directory): directory is string => directory !== null)
.map((directory) => `${directory}/lcov.info`)
// bun writes no report at all for a test file that covered no source file, so a
// missing one is expected here rather than an error.
// A coverage directory may be given as an absolute path, so resolve rather
// than join: path.join("/repo", "/tmp/raw") is "/repo/tmp/raw".
.filter((report) => existsSync(path.resolve(root, report)));
if (reports.length === 0) {
throw new Error(`No coverage report was written under ${options.coverageDir}.`);
}
// The list goes in a file rather than in argv: Windows caps a command line at
// 32767 characters and this repository already has over 500 test files.
const manifest = `${options.coverageDir}/inputs.txt`;
writeFileSync(path.resolve(root, manifest), `${reports.join("\n")}\n`);
const merged = Bun.spawnSync(
["node", "scripts/merge-lcov.mjs", `--inputs-from=${manifest}`, options.mergeInto as string],
{ cwd: root, stdout: "inherit", stderr: "inherit" },
);
if (merged.exitCode !== 0) {
// A child that died by a signal reports exitCode null and signalCode instead
// (measured on bun 1.4.2), and a terminal Ctrl+C reaches this child too, because
// it goes to the whole foreground group. "failed with exit null" names no cause.
const how = merged.signalCode ? `was killed by ${merged.signalCode}` : `failed with exit ${merged.exitCode}`;
throw new Error(`Merging ${reports.length} coverage reports ${how}.`);
}
}
async function main(): Promise {
const options = parseRunnerArgs(process.argv.slice(2), { cpuCount: availableParallelism() });
const files = selectTestFiles(root, options.selectors);
if (options.list) {
await writtenOrReaderGone(process.stdout, `${files.join("\n")}\n`);
return 0;
}
// Decided before anything is deleted or started: a run that has to be refused (a CI job whose
// Helm is missing) refuses with the coverage directory and the merged report still intact.
const plan = planRequirements({
files,
readSource: (file) => readFileSync(path.join(root, file), "utf8"),
missing: { helm: () => missingHelm(systemHelmProbe(root)) },
required: requiredCapabilities(process.env),
});
if (options.coverage) {
const coverageDir = path.resolve(root, options.coverageDir);
// --coverage-dir is a path the caller chooses and this line deletes it, so it is
// checked before it is emptied. See tests/runner/coverage.ts.
if (existsSync(coverageDir)) assertCoverageDirIsOurs(options.coverageDir, readdirSync(coverageDir));
rmSync(coverageDir, { recursive: true, force: true });
mkdirSync(coverageDir, { recursive: true });
}
// The merged report goes too, and before the run rather than after it: a run that
// ends red never reaches the merge, and a stale lcov left beside it is a report of
// a tree that no longer exists, which `coverage:check` would happily pass.
if (options.mergeInto) {
const mergeTarget = path.resolve(root, options.mergeInto);
if (existsSync(mergeTarget)) assertMergeTargetIsOurs(options.mergeInto, readFileSync(mergeTarget, "utf8"));
rmSync(mergeTarget, { force: true });
}
const junitDir = mkdtempSync(path.join(tmpdir(), "libredb-test-junit-"));
runScratch = junitDir;
const selection = options.selectors.length > 0 ? options.selectors.join(" ") : "tests/";
const notRunNote =
plan.notRun.length > 0 ? ` (${plan.notRun.length} not run on this machine, listed at the end)` : "";
runStarted = true;
process.stdout.write(
`bun ${Bun.version} on ${process.platform}-${process.arch}: ${plan.run.length} files from ${selection}${notRunNote}, ` +
`${options.jobs} at a time${options.coverage ? ", with coverage" : ""}\n\n`,
);
const summary = await runTestFiles({
files: plan.run,
jobs: options.jobs,
timeoutMs: options.fileTimeoutMs,
coverage: options.coverage,
coverageDir: options.coverageDir,
coverageExempt: COVERAGE_EXEMPT_FILES,
runFile: spawnTestFile(options.bunArgs, junitDir),
shouldStop: () => stopping,
onResult: (outcome, position, total) => {
// Belt and braces. The intent is that what a child has just been killed for is
// not this file's verdict, but by the time a killed child's outcome could arrive
// the signal handler has usually already called process.exit, so removing this
// guard leaves the whole suite green (measured by mutation). It is written from
// the shape of the code, for the window where the handler's own exit is still
// pending, and not from an observed behaviour.
if (stopping) return;
process.stdout.write(`${formatFileLine(outcome, position, total)}\n`);
// A failing file's whole output is printed where it lands rather than kept for
// the end: a CI log is read from the first red line downwards.
if (outcome.status !== "passed") process.stdout.write(`${outcome.output}\n`);
},
});
// A run that was signalled prints no summary and carries no code of its own: the
// handler is already on its way out with 128 + the signal's number, and returning
// its promise waits for it rather than reporting the files it had killed as
// failures. Belt and braces like the guard above: the handler normally exits before
// this is reached, so removing it leaves the suite green (measured by mutation).
if (interruption !== null) return interruption;
await writtenOrReaderGone(process.stdout, `${formatSummary(summary, plan.notRun)}\n`);
// Looked at again, because a megabyte of summary takes a while to drain and a signal
// taken while it did still owns the way out. Belt and braces once more: the window is
// between this write resolving and the handler's own exit, and nothing here can drive
// it to order, so no test pins this line.
if (interruption !== null) return interruption;
if (summary.failures.length > 0) return 1;
if (options.mergeInto) {
mergeCoverage(options, plan.run);
// mergeCoverage is synchronous and merges 500+ reports, so a signal delivered
// during it is still queued when it returns: measured on bun 1.4.2, the listener
// has NOT run at that point, and without this turn of the event loop the
// process.exit(0) below wins and the user's Ctrl+C vanishes with no Interrupted
// line and a green exit. One setImmediate is enough (measured, 5 runs of 5),
// because libuv polls its signal handles before the check phase.
await new Promise((resolve) => setImmediate(resolve));
if (interruption !== null) return interruption;
}
return 0;
}
/**
* Ends the run on a signal, with 128 + the signal's number, which is what a shell
* reports: 130 for SIGINT, 143 for SIGTERM, 129 for SIGHUP (see tests/runner/signals.ts,
* which also carries the number for SIGBREAK, that no POSIX table has).
*
* The children are asked to stop and are not waited for: `--no-orphans` takes every
* child and its descendants down once this process has gone (measured), and on POSIX
* a directory is removed happily while a dying child still holds a file in it open.
* A removal that fails is named and exits 2 instead, never swallowed: a throw from
* inside a signal listener leaves bun running, and the queue then starts the next
* file (measured 1.4.2), which is worse than either.
*
* Nothing here waits on anything it does not control. The listeners come off first, so
* the default disposition is back and a second Ctrl+C really kills (measured on 1.4.2:
* removing every listener for a signal restores it, and the second SIGINT then ends the
* process with 130 by itself). The last line is then raced against a grace, because a
* reader that has stopped reading must not be able to keep a signalled run alive.
*/
function stopOnSignal(signal: StopSignal): Promise {
stopRun();
for (const other of STOP_SIGNALS) process.removeAllListeners(other);
const removal = removeRunScratch();
return exitAfterWriting(
removal === null ? exitCodeForSignal(signal) : 2,
{
stdout: `\nInterrupted (${signal}).\n`,
stderr: removal === null ? undefined : `${removal}\n`,
},
SIGNAL_WRITE_GRACE_MS,
);
}
/** Set once a signal has been taken: the run has no verdict of its own after that. */
let interruption: Promise | null = null;
for (const signal of STOP_SIGNALS) {
// The first signal owns the way out; a second one arriving while it writes its last
// line must not start a second cleanup over the top of it. It is not swallowed
// either: stopOnSignal has already put the default disposition back, so the second
// one kills the process outright rather than reaching this listener at all.
process.on(signal, () => {
interruption ??= stopOnSignal(signal);
});
}
try {
const code = await main();
const removal = removeRunScratch();
// Usage and setup errors exit 2, so a caller can tell "the tests failed" (1) from
// "the runner could not run them" (2). A scratch directory left behind is the
// second kind, so it takes the run's own code away.
if (removal !== null) await exitAfterWriting(2, { stderr: `${removal}\n` });
// The last look: a signal taken in the turn between main's own final check and this
// line still owns the way out, and this promise never resolves, so the handler's
// exit is what happens. Belt and braces, like main's own two checks.
if (interruption !== null) await interruption;
process.exit(code);
} catch (error) {
// The children are stopped before the message is written: the write is awaited, and
// while it is, a worker would otherwise start the next file over a run that is over.
stopRun();
const removal = removeRunScratch();
const reason = error instanceof Error ? error.message : String(error);
await exitAfterWriting(2, {
// Whoever is reading the run is reading stdout, and whatever landed there before
// the error still has to reach them, which needs a write of its own.
stdout: runStarted ? "\nThe run stopped before it finished; the reason is on stderr.\n" : undefined,
stderr: `${[reason, removal].filter((line) => line !== null).join("\n")}\n`,
});
}