Files
bun-src/scripts/runner.node.mjs
2026-08-27 21:09:14 +00:00

3426 lines
114 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#! /usr/bin/env node
// This is a script that runs `bun test` to test Bun itself.
// It is not intended to be used as a test runner for other projects.
//
// - It runs each `bun test` in a separate process, to catch crashes.
// - It cannot use Bun APIs, since it is run using Node.js.
// - It does not import dependencies, so it's faster to start.
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
accessSync,
appendFileSync,
existsSync,
constants as fs,
linkSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
unlink,
unlinkSync,
writeFileSync,
} from "node:fs";
import { readFile } from "node:fs/promises";
import { availableParallelism, userInfo } from "node:os";
import { basename, dirname, extname, join, relative, sep } from "node:path";
import { createInterface } from "node:readline";
import { setTimeout as setTimeoutPromise } from "node:timers/promises";
import { parseArgs } from "node:util";
import { prestartMap as dockerPrestartMap } from "../test/docker/prestart-map.mjs";
import pLimit from "./p-limit.mjs";
import {
getAbi,
getAbiVersion,
getArch,
getBranch,
getBuildLabel,
getBuildMetadata,
getBuildUrl,
getCommit,
getDistro,
getDistroVersion,
getEnv,
getFileUrl,
getHostname,
getLoggedInUserCountOrDetails,
getOs,
getSecret,
getShell,
getWindowsExitReason,
isAndroid,
isBuildkite,
isCI,
isGithubAction,
isLinux,
isMacOS,
isWindows,
isX64,
markBuildkiteStepReported,
printEnvironment,
reportAnnotationToBuildKite,
startGroup,
tmpdir,
unzip,
uploadArtifact,
} from "./utils.mjs";
let isQuiet = false;
const cwd = import.meta.dirname ? dirname(import.meta.dirname) : process.cwd();
const testsPath = join(cwd, "test");
const runnerStartedAt = Date.now();
const jobBudgetMs = () => {
const minutes = parseInt(process.env.BUILDKITE_TIMEOUT || "", 10);
if (!Number.isFinite(minutes) || minutes <= 0) return Infinity;
return minutes * 60_000 - (Date.now() - runnerStartedAt);
};
const spawnTimeout = 5_000;
const spawnBunTimeout = 20_000; // when running with ASAN/LSAN bun can take a bit longer to exit, not a bug.
const testTimeout = 3 * 60_000;
const integrationTimeout = 5 * 60_000;
const resolutionGatingFlags = new Set([
"--expose-internals",
"--experimental-quic",
"--experimental-stream-iter",
"--no-warnings",
]);
function getNodeParallelTestTimeout(testPath) {
if (testPath.includes("test-dns")) return 60_000;
if (testPath.includes("test-cluster-")) return 60_000; // cluster IPC + socket-handle passing is process-heavy under runner concurrency
if (testPath.includes("-docker-")) return 60_000;
if (testPath.includes("test-stdin-pipe-large")) return 60_000; // pipes 1MB stdin->stdout through an extra child process; slow under runner concurrency
// test-fs-read-stream-pos.js exit condition is a pure timing race (writer must append
// between two consecutive ReadStream preads) with a 90s upstream safety timer; solo
// runtimes are ~1s on linux-x64 but 1-40s on Windows since #34834 raised its timer
// resolution, and aarch64 CI retries running alone have exceeded 20s (builds 85866,
// 85400). 120s lets the safety timer fire.
if (testPath.includes("test-fs-read-stream-pos")) return 120_000;
if (!isCI) return 60_000; // everything slower in debug mode
if (options["step"]?.includes("-asan-")) return 60_000;
return 20_000;
}
process.on("SIGTRAP", () => {
console.warn("Test runner received SIGTRAP. Doing nothing.");
});
const { values: options, positionals: filters } = parseArgs({
allowPositionals: true,
options: {
["node-tests"]: {
type: "boolean",
default: false,
},
/** Path to bun binary */
["exec-path"]: {
type: "string",
default: "bun",
},
["step"]: {
type: "string",
default: undefined,
},
["build-id"]: {
type: "string",
default: undefined,
},
["bail"]: {
type: "boolean",
default: false,
},
["shard"]: {
type: "string",
default: getEnv("BUILDKITE_PARALLEL_JOB", false) || "0",
},
["max-shards"]: {
type: "string",
default: getEnv("BUILDKITE_PARALLEL_JOB_COUNT", false) || "1",
},
["include"]: {
type: "string",
multiple: true,
default: undefined,
},
["exclude"]: {
type: "string",
multiple: true,
default: undefined,
},
["skip-slower-than"]: {
type: "string",
default: undefined,
},
["quiet"]: {
type: "boolean",
default: false,
},
["smoke"]: {
type: "string",
default: undefined,
},
["vendor"]: {
type: "string",
default: undefined,
},
["retries"]: {
type: "string",
default: isCI ? "3" : "0", // N retries = N+1 attempts
},
["junit"]: {
type: "boolean",
default: false, // Disabled for now, because it's too much $
},
["junit-temp-dir"]: {
type: "string",
default: "junit-reports",
},
["junit-upload"]: {
type: "boolean",
default: isBuildkite,
},
["coredump-upload"]: {
type: "boolean",
default: isBuildkite && isLinux && !isAndroid,
},
["parallel"]: {
type: "boolean",
default: false,
},
/** Write per-file results as JSON to this path (for test-fix workflows). */
["results-json"]: {
type: "string",
default: undefined,
},
},
});
const cliOptions = options;
if (cliOptions.junit) {
try {
cliOptions["junit-temp-dir"] = mkdtempSync(join(tmpdir(), cliOptions["junit-temp-dir"]));
} catch (err) {
cliOptions.junit = false;
console.error(`Error creating JUnit temp directory: ${err.message}`);
}
}
if (options["quiet"]) {
isQuiet = true;
}
/** @type {string[]} */
let allFiles = [];
/** @type {string[]} */
let newFiles = [];
let prFileCount = 0;
if (isBuildkite) {
// The pipeline-upload step (.buildkite/ci.mjs) already fetched the PR file
// list once and stored it as build meta-data. Read it from there so each of
// the ~150 test shards doesn't repeat the GitHub API call and burn through
// the token's hourly rate limit.
const cachedAll = await getBuildMetadata("pr-all-files");
const cachedNew = await getBuildMetadata("pr-new-files");
if (cachedAll) {
try {
allFiles = JSON.parse(cachedAll);
newFiles = cachedNew ? JSON.parse(cachedNew) : [];
prFileCount = allFiles.length;
console.log(`- PR file list from build meta-data: ${prFileCount} files, ${newFiles.length} new files`);
} catch (e) {
console.error("Failed to parse pr-*-files meta-data:", e);
allFiles = [];
newFiles = [];
}
}
if (allFiles.length === 0) {
try {
console.log("on buildkite: collecting new files from PR");
const per_page = 50;
const { BUILDKITE_PULL_REQUEST } = process.env;
for (let i = 1; i <= 10; i++) {
const res = await fetch(
`https://api.github.com/repos/oven-sh/bun/pulls/${BUILDKITE_PULL_REQUEST}/files?per_page=${per_page}&page=${i}`,
{ headers: { Authorization: `Bearer ${getSecret("GITHUB_TOKEN")}` } },
);
const doc = await res.json();
if (!res.ok || !Array.isArray(doc)) {
console.error(`-> page ${i}: GitHub API ${res.status} ${res.statusText}:`, JSON.stringify(doc));
throw new Error(`GitHub API returned ${res.status}; cannot determine changed files`);
}
console.log(`-> page ${i}, found ${doc.length} items`);
if (doc.length === 0) break;
for (const { filename, status } of doc) {
prFileCount += 1;
allFiles.push(filename);
if (status !== "added") continue;
newFiles.push(filename);
}
if (doc.length < per_page) break;
}
console.log(`- PR ${BUILDKITE_PULL_REQUEST}, ${prFileCount} files, ${newFiles.length} new files`);
} catch (e) {
console.error(e);
}
}
}
let coresDir;
if (options["coredump-upload"]) {
// this sysctl is set in bootstrap.sh to /var/bun-cores-$distro-$release-$arch
const sysctl = await spawnSafe({ command: "sysctl", args: ["-n", "kernel.core_pattern"] });
coresDir = sysctl.stdout;
if (sysctl.ok) {
if (coresDir.startsWith("|")) {
throw new Error("cores are being piped not saved");
}
// change /foo/bar/%e-%p.core to /foo/bar
coresDir = dirname(sysctl.stdout);
} else {
throw new Error(`Failed to check core_pattern: ${sysctl.error}`);
}
}
let remapPort = undefined;
/**
* @typedef {Object} TestExpectation
* @property {string} filename
* @property {string[]} expectations
* @property {string[] | undefined} bugs
* @property {string[] | undefined} modifiers
* @property {string | undefined} comment
*/
/**
* @returns {TestExpectation[]}
*/
function getTestExpectations() {
const expectationsPath = join(cwd, "test", "expectations.txt");
if (!existsSync(expectationsPath)) {
return [];
}
const lines = readFileSync(expectationsPath, "utf-8").split(/\r?\n/);
/** @type {TestExpectation[]} */
const expectations = [];
for (const line of lines) {
const content = line.trim();
if (!content || content.startsWith("#")) {
continue;
}
let comment;
const commentIndex = content.indexOf("#");
let cleanLine = content;
if (commentIndex !== -1) {
comment = content.substring(commentIndex + 1).trim();
cleanLine = content.substring(0, commentIndex).trim();
}
let modifiers = [];
let remaining = cleanLine;
let modifierMatch = remaining.match(/^\[(.*?)\]/);
if (modifierMatch) {
modifiers = modifierMatch[1].trim().split(/\s+/);
remaining = remaining.substring(modifierMatch[0].length).trim();
}
let expectationValues = ["Skip"];
const expectationMatch = remaining.match(/\[(.*?)\]$/);
if (expectationMatch) {
expectationValues = expectationMatch[1].trim().split(/\s+/);
remaining = remaining.substring(0, remaining.length - expectationMatch[0].length).trim();
}
const filename = remaining.trim();
if (filename) {
expectations.push({
filename,
expectations: expectationValues,
bugs: undefined,
modifiers: modifiers.length ? modifiers : undefined,
comment,
});
}
}
return expectations;
}
const skipsForExceptionValidation = (() => {
const path = join(cwd, "test/no-validate-exceptions.txt");
if (!existsSync(path)) {
return [];
}
return readFileSync(path, "utf-8")
.split("\n")
.map(line => line.trim())
.filter(line => !line.startsWith("#") && line.length > 0);
})();
const skipsForLeaksan = (() => {
const path = join(cwd, "test/no-validate-leaksan.txt");
if (!existsSync(path)) {
return [];
}
return readFileSync(path, "utf-8")
.split("\n")
.map(line => line.trim())
.filter(line => !line.startsWith("#") && line.length > 0);
})();
const parallelAllowlist = (() => {
try {
const { dirs, excludeFiles } = JSON.parse(readFileSync(join(cwd, "test", "parallel-allowlist.json"), "utf-8"));
return { dirs: new Set(dirs), excludeFiles: new Set(excludeFiles) };
} catch (error) {
console.warn("test/parallel-allowlist.json not loaded:", error?.message || error);
return { dirs: new Set(), excludeFiles: new Set() };
}
})();
const isParallelAllowlisted = testPath => {
const path = testPath.replaceAll("\\", "/");
const slash = path.lastIndexOf("/");
return (
parallelAllowlist.dirs.has(slash === -1 ? "" : path.slice(0, slash)) && !parallelAllowlist.excludeFiles.has(path)
);
};
const dockerServicePrefixes = Object.keys(dockerPrestartMap);
const needsDockerService = testPath => {
const path = testPath.replaceAll("\\", "/");
return dockerServicePrefixes.some(prefix => path.startsWith(prefix));
};
/**
* Returns whether we should validate exception checks running the given test
* @param {string} test
* @returns {boolean}
*/
const shouldValidateExceptions = test => {
// Skip-list entries use `/`; on Windows callers pass `\`-separated paths
// (path.relative) which never match. Normalize before lookup.
const t = test.replaceAll(sep, "/");
return !(skipsForExceptionValidation.includes(t) || skipsForExceptionValidation.includes("test/" + t));
};
/**
* Returns whether we should validate exception checks running the given test
* @param {string} test
* @returns {boolean}
*/
const shouldValidateLeakSan = test => {
return !(skipsForLeaksan.includes(test) || skipsForLeaksan.includes("test/" + test));
};
/**
* @param {string} testPath
* @returns {string[]}
*/
function getTestModifiers(testPath) {
const ext = extname(testPath);
const filename = basename(testPath, ext);
const modifiers = filename.split("-").filter(value => value !== "bun");
const os = getOs();
const arch = getArch();
modifiers.push(os, arch, `${os}-${arch}`);
const distro = getDistro();
if (distro) {
modifiers.push(distro, `${os}-${distro}`, `${os}-${arch}-${distro}`);
const distroVersion = getDistroVersion();
if (distroVersion) {
modifiers.push(
distroVersion,
`${distro}-${distroVersion}`,
`${os}-${distro}-${distroVersion}`,
`${os}-${arch}-${distro}-${distroVersion}`,
);
}
}
const abi = getAbi();
if (abi) {
modifiers.push(abi, `${os}-${abi}`, `${os}-${arch}-${abi}`);
const abiVersion = getAbiVersion();
if (abiVersion) {
modifiers.push(
abiVersion,
`${abi}-${abiVersion}`,
`${os}-${abi}-${abiVersion}`,
`${os}-${arch}-${abi}-${abiVersion}`,
);
}
}
return modifiers.map(value => value.toUpperCase());
}
/**
* Smoke-checks that this agent actually matches the platform the CI step was
* generated for. The step exports EXPECTED_PLATFORM_* (see getTestBunStep in
* .buildkite/ci.mjs); we compare against the same utils the agent uses to
* emit its tags. Catches misrouted jobs (e.g. a macOS 26 step landing on a
* macOS 15 box) before any test runs, instead of producing silently-wrong
* results for a whole shard.
*/
function assertExpectedPlatform() {
const expectedOs = process.env["EXPECTED_PLATFORM_OS"];
if (!expectedOs) {
return; // step does not declare expectations (e.g. local runs)
}
const checks = [
["os", expectedOs, getOs()],
["arch", process.env["EXPECTED_PLATFORM_ARCH"], getArch()],
["abi", process.env["EXPECTED_PLATFORM_ABI"], getAbi()],
["distro", process.env["EXPECTED_PLATFORM_DISTRO"], getDistro()],
];
const expectedRelease = process.env["EXPECTED_PLATFORM_RELEASE"];
if (expectedRelease) {
// Major-version prefix match: "26" accepts "26.4", "25.04" accepts "25.04.1".
const actualRelease = getDistroVersion();
const ok = actualRelease === expectedRelease || `${actualRelease}.`.startsWith(`${expectedRelease}.`);
checks.push(["release", expectedRelease, ok ? expectedRelease : actualRelease]);
}
// Fail closed: a declared expectation with no detectable actual value is a
// mismatch (e.g. a musl step on a box where the ABI probe fails).
const mismatches = checks.filter(([, expected, actual]) => expected && expected !== actual);
if (mismatches.length) {
const details = mismatches
.map(([k, e, a]) => `${k}: step expects "${e}", agent is ${a ? `"${a}"` : "(undetected)"}`)
.join("\n ");
console.error(`Platform smoke check FAILED, this job landed on the wrong agent:\n ${details}`);
process.exit(1);
}
!isQuiet &&
console.log(
"Platform check:",
checks
.filter(([, e]) => e)
.map(([k, e]) => `${k}=${e}`)
.join(" "),
"(ok)",
);
}
/**
* @returns {Promise<TestResult[]>}
*/
async function runTests() {
assertExpectedPlatform();
let execPath;
if (options["step"]) {
execPath = await getExecPathFromBuildKite(options["step"], options["build-id"]);
} else {
execPath = getExecPath(options["exec-path"]);
}
!isQuiet && console.log("Bun:", execPath);
const expectations = getTestExpectations();
const modifiers = getTestModifiers(execPath);
!isQuiet && console.log("Modifiers:", modifiers);
const revision = getRevision(execPath);
!isQuiet && console.log("Revision:", revision);
const tests = getRelevantTests(testsPath, modifiers, expectations);
!isQuiet && console.log("Running tests:", tests.length);
// Start the docker-service coordinator (test/docker/coordinator.ts). It
// owns every `docker compose` invocation for this shard — `compose up` is
// not concurrency-safe, so exactly one process runs it — and prestarts the
// services this shard's tests need (mysql/postgres/redis/minio/…) in the
// background while getVendorTests below installs vendor deps. Tests reach
// it through the unix socket in BUN_DOCKER_COORDINATOR (inherited by every
// spawned test process); ensure() waits for the coordinator's ready message
// instead of shelling out to compose itself. Without the env var, ensure()
// falls back to running compose directly. Linux-only — macOS / Windows CI
// don't run docker tests. Lifetime is tied to this process via the stdin
// pipe.
if (isCI && isLinux && spawnSync("docker", ["compose", "version"], { stdio: "ignore" }).status === 0) {
const coordinatorSocket = join(tmpdir(), `bun-docker-${process.pid}.sock`);
const coordinator = spawn(execPath, [join(cwd, "test", "docker", "coordinator.ts"), ...tests], {
stdio: ["pipe", "pipe", "inherit"],
env: { ...process.env, BUN_DOCKER_COORDINATOR_SOCKET: coordinatorSocket },
});
coordinator.on("error", err => console.warn("docker coordinator spawn failed:", err.message));
process.once("exit", () => coordinator.kill());
// Don't point tests at the socket until it's actually listening; if the
// coordinator never gets there, tests use the direct-compose fallback.
const ready = await new Promise(resolve => {
const timer = setTimeout(() => resolve(false), 15_000);
timer.unref?.();
let buffered = "";
coordinator.stdout.on("data", chunk => {
process.stdout.write(chunk);
if (buffered !== null) {
buffered += chunk;
if (buffered.includes("COORDINATOR_READY")) {
buffered = null;
clearTimeout(timer);
resolve(true);
}
}
});
coordinator.once("exit", () => {
clearTimeout(timer);
resolve(false);
});
coordinator.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
if (ready) {
process.env.BUN_DOCKER_COORDINATOR = coordinatorSocket;
} else {
console.warn("docker coordinator did not become ready; tests will run compose directly");
coordinator.kill();
}
// Never keep the runner alive on the coordinator's account; it exits on
// stdin EOF when the runner is gone.
coordinator.unref();
coordinator.stdin?.unref?.();
coordinator.stdout?.unref?.();
}
/** @type {VendorTest[] | undefined} */
let vendorTests;
let vendorTotal = 0;
if (/true|1|yes|on/i.test(options["vendor"]) || (isCI && typeof options["vendor"] === "undefined")) {
vendorTests = await getVendorTests(cwd);
if (vendorTests.length) {
vendorTotal = vendorTests.reduce((total, { testPaths }) => total + testPaths.length + 1, 0);
!isQuiet && console.log("Running vendor tests:", vendorTotal);
}
}
let i = 0;
let total = vendorTotal + tests.length + 2;
const okResults = [];
const flakyResults = [];
const flakyResultsTitles = [];
const failedResults = [];
const failedResultsTitles = [];
const maxAttempts = 1 + (parseInt(options["retries"]) || 0);
const parallelism = options["parallel"] ? availableParallelism() : 1;
console.log("parallelism", parallelism);
const limit = pLimit(parallelism);
// Test paths that are process-parallel safe by naming convention
// (js/node/test/parallel/, js/bun/test/parallel/): ~2.8k files with a ~50ms
// median that spend almost all their wall time in `bun` startup. These run
// as a separate N-wide phase AFTER every other test in the shard has
// finished, so no parallel-safe process ever overlaps a serial test (an
// overlap surfaced new flakes in tight-timeout / port-bind tests such as
// dns-tcp-bidirectional-poll and test-https-timeout). `--parallel` already
// widens `limit` above and supersedes this split. macOS is capped at 4: the
// Intel minis are 6-core i7-8700B (12 HT threads => width 11), and each
// M2 Ultra host runs two 18-vCPU tart VMs (width 17 apiece, 34 on 24
// cores when both are busy); both hit the 45-minute job timeout uncapped.
const parallelSafeCap = isMacOS ? 4 : Infinity;
const parallelSafeWidth =
parallelism > 1 ? parallelism : Math.min(parallelSafeCap, Math.max(availableParallelism() - 1, 1));
const parallelSafeLimit = parallelism > 1 ? limit : pLimit(parallelSafeWidth);
const isParallelSafeTest = testPath => {
const p = testPath.replaceAll("\\", "/");
if (!p.includes("js/node/test/parallel/") && !p.includes("js/bun/test/parallel/")) return false;
// test-fs-read-stream-pos.js arms a common.mustCallAtLeast per stream; under
// I/O-heavy neighbours (e.g. test-fs-read-stream-fd-leak) the 1ms append
// interval is starved long enough for a stream to catch cur == EOF and get
// zero 'data' events, failing the mustCallAtLeast check on exit. Run it in
// the serial phase so the writer keeps its 1ms cadence.
if (p.endsWith("test-fs-read-stream-pos.js")) return false;
return true;
};
console.log("parallel-safe width", parallelSafeWidth);
const validationApplies = basename(execPath).includes("asan") || !isCI;
/**
* @param {string} title
* @param {function} fn
* @param {boolean} [concurrent] this call may overlap with other runTest calls
* @returns {Promise<TestResult>}
*/
const runTest = async (title, fn, concurrent = parallelism > 1) => {
const index = ++i;
let result, failure, flaky;
let attempt = 1;
for (; attempt <= maxAttempts; attempt++) {
if (attempt > 1) {
await new Promise(resolve => setTimeout(resolve, 5000 + Math.random() * 10_000));
}
let grouptitle = `${getAnsi("gray")}[${index}/${total}]${getAnsi("reset")} ${title}`;
if (attempt > 1) grouptitle += ` ${getAnsi("gray")}[attempt #${attempt}]${getAnsi("reset")}`;
if (concurrent) {
console.log(grouptitle);
result = await fn(index);
} else {
result = await startGroup(grouptitle, () => fn(index));
}
const { ok, stdoutPreview, error } = result;
if (ok) {
if (failure) {
flakyResults.push(failure);
flakyResultsTitles.push(title);
} else {
okResults.push(result);
}
break;
}
const color = attempt >= maxAttempts ? "red" : "yellow";
const label = `${getAnsi(color)}[${index}/${total}] ${title} - ${error}${getAnsi("reset")}`;
if (concurrent) {
// Don't open a group mid-phase: it would re-anchor the log viewer's
// folding for every concurrent title printed after it.
console.log(label);
} else {
startGroup(label, () => {
if (!isCI) return;
process.stderr.write(stdoutPreview);
});
}
failure ||= result;
flaky ||= true;
if (attempt >= maxAttempts || isAlwaysFailure(error)) {
flaky = false;
failedResults.push(failure);
failedResultsTitles.push(title);
break;
}
}
if (!failure) {
return result;
}
if (isBuildkite) {
// Group flaky tests together, regardless of the title
const context = flaky ? "flaky" : title;
const style = flaky ? "warning" : "error";
if (!flaky) attempt = 1; // no need to show the retries count on failures, we know it maxed out
if (title.startsWith("vendor")) {
const content = formatTestToMarkdown({ ...failure, testPath: title }, false, attempt - 1);
if (content) {
reportAnnotationToBuildKite({ context, label: title, content, style });
}
} else {
const content = formatTestToMarkdown(failure, false, attempt - 1);
if (content) {
reportAnnotationToBuildKite({ context, label: title, content, style });
}
}
}
if (isGithubAction) {
const summaryPath = process.env["GITHUB_STEP_SUMMARY"];
if (summaryPath) {
const longMarkdown = formatTestToMarkdown(failure, false, attempt - 1);
appendFileSync(summaryPath, longMarkdown);
}
const shortMarkdown = formatTestToMarkdown(failure, true, attempt - 1);
appendFileSync("comment.md", shortMarkdown);
}
if (options["bail"]) {
markBuildkiteStepReported();
process.exit(getExitCode("fail"));
}
return result;
};
if (!isQuiet) {
for (const path of [cwd, testsPath]) {
const title = relative(cwd, join(path, "package.json")).replace(/\\/g, "/");
await runTest(title, async () => spawnBunInstall(execPath, { cwd: path }));
}
}
const isNapiAddonTest = t => /napi[\\/]node-napi-tests[\\/].*[\\/]do\.test\.ts$/.test(t);
const napiAddonDirs = [...new Set(tests.filter(isNapiAddonTest).map(t => dirname(join(testsPath, t))))];
let napiPrebuild = null;
if (napiAddonDirs.length) {
let output = "";
const started = Date.now();
napiPrebuild = spawnBun(execPath, {
args: [join(testsPath, "napi/node-napi-tests/prebuild.ts"), ...napiAddonDirs],
cwd,
timeout: integrationTimeout,
stdout: chunk => (output += chunk),
stderr: chunk => (output += chunk),
}).then(({ ok, error }) => ({ ok, error, output, seconds: (Date.now() - started) / 1000 }));
}
const awaitNapiPrebuild = async testPath => {
if (napiPrebuild && isNapiAddonTest(testPath)) {
const { ok, error, output, seconds } = await napiPrebuild;
napiPrebuild = null;
startGroup(`napi prebuild: ${napiAddonDirs.length} addon(s), ${seconds.toFixed(1)}s`);
process.stdout.write(output);
if (!ok) console.warn("napi prebuild failed; each do.test.ts builds its own addon:", error);
}
};
if (!failedResults.length) {
// TODO: remove windows exclusion here
if (isCI && !isWindows) {
// bun install has succeeded
const { promise: portPromise, resolve: portResolve } = Promise.withResolvers();
const { promise: errorPromise, resolve: errorResolve } = Promise.withResolvers();
console.log("run in", cwd);
let exiting = false;
const server = spawn(execPath, ["run", "--silent", "ci-remap-server", execPath, cwd, getCommit()], {
stdio: ["ignore", "pipe", "inherit"],
cwd, // run in main repo
env: { ...process.env, BUN_DEBUG_QUIET_LOGS: "1", NO_COLOR: "1" },
});
server.unref();
server.on("error", errorResolve);
server.on("exit", (code, signal) => {
if (!exiting && (code !== 0 || signal !== null)) errorResolve(signal ? signal : "code " + code);
});
function onBeforeExit() {
exiting = true;
server.off("error");
server.off("exit");
server.kill?.();
}
process.once("beforeExit", onBeforeExit);
const lines = createInterface(server.stdout);
lines.on("line", line => {
portResolve({ port: parseInt(line) });
});
const result = await Promise.race([portPromise, errorPromise.catch(e => e), setTimeoutPromise(5000, "timeout")]);
if (typeof result?.port != "number") {
process.off("beforeExit", onBeforeExit);
server.kill?.();
console.warn("ci-remap server did not start:", result);
} else {
console.log("crash reports parsed on port", result.port);
remapPort = result.port;
}
}
const runOneTest = async (testPath, concurrent) => {
await awaitNapiPrebuild(testPath);
const absoluteTestPath = join(testsPath, testPath);
const title = relative(cwd, absoluteTestPath).replaceAll(sep, "/");
if (isNodeTest(testPath)) {
const testContent = readFileSync(absoluteTestPath, "utf-8");
const flagsMatch = /^\/\/ Flags:[^\S\r\n]+(--[^\r\n]*)$/m.exec(testContent);
const testFlags = flagsMatch
? flagsMatch[1].split(/\s+/).filter(flag => resolutionGatingFlags.has(flag.split("=")[0]))
: [];
let runWithBunTest = title.includes("needs-test") || testContent.includes("node:test");
// don't wanna have a filter for includes("bun:test") but these need our mocks
runWithBunTest ||= title === "test/js/node/test/parallel/test-fs-append-file-flush.js";
runWithBunTest ||= title === "test/js/node/test/parallel/test-fs-write-file-flush.js";
runWithBunTest ||= title === "test/js/node/test/parallel/test-fs-write-stream-flush.js";
// A file that only drives node:test's run() is the parent of the run,
// not a test file: Node executes it as a plain script, and under
// `bun test` a file registering no tests of its own exits before its
// run() finishes. Files that also register tests at the top level (as
// opposed to inside a NODE_TEST_CONTEXT child branch) still need
// `bun test`. run() spawns its own children with `bun test`.
const importsRun =
/\brun\b[^\n]*=\s*require\(['"]node:test['"]\)/.test(testContent) ||
/import\s*{[^}]*\brun\b[^}]*}\s*from\s*['"]node:test['"]/.test(testContent);
// Registrations behind a NODE_TEST_CONTEXT guard belong to the child
// run() spawns, not to this process; an unindented one is this file's
// own and must keep `bun test`, or it silently never runs and the file
// "passes" having tested nothing. Requiring the guard as well keeps a
// file whose only registrations are indented for some other reason
// (inside an `if`, an IIFE) on `bun test`, where the worst case is a
// real run rather than a vacuous pass.
const registersAtColumnZero = /^(?:test|it|describe|suite)\s*[.(]/m.test(testContent);
const registersTests = /(?:^|[^.\w])(?:test|it|describe|suite)\s*[.(]/m.test(testContent);
const guardsOnTestContext = testContent.includes("NODE_TEST_CONTEXT");
const isRunDriver = importsRun && !registersAtColumnZero && (!registersTests || guardsOnTestContext);
// The needs-test filename opt-in wins over this heuristic.
if (isRunDriver && !title.includes("needs-test")) runWithBunTest = false;
const subcommand = runWithBunTest ? "test" : "run";
const env = {
FORCE_COLOR: "0",
NO_COLOR: "1",
BUN_DEBUG_QUIET_LOGS: "1",
// Node parity: a node test process exits only when its event loop
// drains, and common.mustCall() verifies counts in 'exit' handlers.
BUN_TEST_DRAIN_EVENT_LOOP: "1",
};
if (title.includes("test-util-styletext")) {
// These assert styleText's own color decisions against a TTY, so they need a
// color-capable environment they can then override per case. Drop the forced
// settings above, spawnBun's FORCE_COLOR, and CI (which resolves to "no color"
// in containers that don't identify the vendor), and pin TERM so every agent
// agrees.
env.FORCE_COLOR = undefined;
env.NO_COLOR = undefined;
env.NODE_DISABLE_COLORS = undefined;
env.CI = undefined;
env.TERM = "xterm-256color";
}
if (!isWindows && title.includes("/sequential/")) {
// Sequential node tests share common.PORT (12346); a cluster worker
// or child_process subprocess that outlives its test can keep that
// port bound and flake the next file. --no-orphans wires
// PR_SET_PDEATHSIG / kqueue parent watch so the whole tree dies with
// the test process. Scoped to sequential/ because parallel/ has
// tests that assert a detached grandchild survives its parent
// (test-child-process-*-detached.js), which this flag defeats.
env.BUN_FEATURE_FLAG_NO_ORPHANS = "1";
}
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateExceptions(testPath)) {
env.BUN_JSC_validateExceptionChecks = "1";
env.BUN_JSC_dumpSimulatedThrows = "1";
}
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateLeakSan(testPath)) {
env.BUN_DESTRUCT_VM_ON_EXIT = "1";
env.ASAN_OPTIONS = "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1";
// prettier-ignore
env.LSAN_OPTIONS = `malloc_context_size=30:print_suppressions=0:suppressions=${process.cwd()}/test/leaksan.supp`;
}
return runTest(
title,
async index => {
const { ok, error, stdout, crashes } = await spawnBun(execPath, {
cwd: cwd,
args: [
subcommand,
"--config=" + join(import.meta.dirname, "../bunfig.node-test.toml"),
...(subcommand === "run" ? testFlags : []),
absoluteTestPath,
],
timeout: getNodeParallelTestTimeout(title),
env: {
...env,
// test/common/tmpdir.js derives `.tmp.${TEST_SERIAL_ID}` from this;
// a unique value per test file keeps concurrent tmpdir.refresh()
// calls from wiping each other when parallelSafeWidth > 1.
TEST_SERIAL_ID: String(index),
},
stdout: concurrent ? () => {} : chunk => pipeTestStdout(process.stdout, chunk),
stderr: concurrent ? () => {} : chunk => pipeTestStdout(process.stderr, chunk),
});
const mb = 1024 ** 3;
let stdoutPreview = stdout.slice(0, mb).split("\n").slice(0, 50).join("\n");
if (crashes) stdoutPreview += crashes;
return {
testPath: title,
ok: ok,
status: ok ? "pass" : "fail",
error: error,
errors: [],
tests: [],
stdout: stdout,
stdoutPreview: stdoutPreview,
};
},
concurrent,
);
} else {
return runTest(
title,
async () =>
spawnBunTest(execPath, join("test", testPath), {
cwd,
stdout: concurrent ? () => {} : chunk => pipeTestStdout(process.stdout, chunk),
stderr: concurrent ? () => {} : chunk => pipeTestStdout(process.stderr, chunk),
}),
concurrent,
);
}
};
const runParallelBucket = async bucketFiles => {
for (const t of bucketFiles) await awaitNapiPrebuild(t);
const width = parallelSafeWidth;
const label = `${bucketFiles.length} files in parallel (${width}×)`;
const range = `${getAnsi("gray")}[${i + 1}-${i + bucketFiles.length}/${total}]${getAnsi("reset")}`;
const junitPath = join(
cwd,
`parallel-bucket-junit-${(options["step"] || getOs()).replace(/[^a-zA-Z0-9._-]/g, "_")}-shard${options["shard"] ?? 0}.xml`,
);
const isAsan = basename(execPath).includes("asan");
const perTestTimeout = Math.ceil(testTimeout / 2) * (isAsan ? 3 : 1);
const env = {};
if (validationApplies) {
env.BUN_JSC_validateExceptionChecks = "1";
env.BUN_JSC_dumpSimulatedThrows = "1";
env.BUN_DESTRUCT_VM_ON_EXIT = "1";
env.ASAN_OPTIONS = "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1";
env.LSAN_OPTIONS = `malloc_context_size=30:print_suppressions=0:suppressions=${process.cwd()}/test/leaksan.supp`;
}
if (isAsan) env.BUN_FEATURE_FLAG_NO_ORPHANS = "1";
const byPath = new Map(bucketFiles.map(t => [join("test", t).replaceAll("\\", "/"), t]));
const norm = p =>
byPath.get(
p
.trim()
.replace(/:$/, "")
.replace(/ \(\d+s\)$/, "")
.replaceAll("\\", "/"),
);
const { ok, error, stdout, crashes } = await startGroup(`${range} ${label}`, () =>
spawnBun(execPath, {
args: [
"test",
`--parallel=${width}`,
`--timeout=${perTestTimeout}`,
"--dots",
"--reporter=junit",
`--reporter-outfile=${junitPath}`,
...bucketFiles.map(t => join(testsPath, t)),
],
cwd,
timeout: Math.max(
60_000,
Math.min(Math.max(10 * 60_000, bucketFiles.length * 5_000), jobBudgetMs() - 5 * 60_000),
),
idleTimeout: parseInt(process.env.BUN_RUNNER_BATCH_IDLE_MS || "", 10) || 4 * 60_000,
gracefulTimeout: true,
env,
stdout: chunk => pipeTestStdout(process.stdout, chunk),
stderr: chunk => pipeTestStdout(process.stderr, chunk),
}),
);
if (crashes) process.stderr.write(crashes);
const suites = new Map(); // repo-relative path -> { failures, seconds, cases: [{name, message}] }
const unescapeXml = str =>
str
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
try {
const xml = readFileSync(junitPath, "utf-8");
for (const [, attrs] of xml.matchAll(/<testsuite\b([^>]*)>/g)) {
const file = /\bfile="([^"]+)"/.exec(attrs)?.[1];
const failures = Number(/\bfailures="(\d+)"/.exec(attrs)?.[1] ?? 0);
const seconds = Number(/\btime="([\d.]+)"/.exec(attrs)?.[1] ?? 0);
if (file) suites.set(unescapeXml(file).replaceAll("\\", "/"), { failures, seconds, cases: [] });
}
for (const [, caseAttrs, failureAttrs] of xml.matchAll(/<testcase\b([^>]*)>\s*<failure\b([^>]*)>/g)) {
const file = /\bfile="([^"]+)"/.exec(caseAttrs)?.[1];
const entry = file && suites.get(unescapeXml(file).replaceAll("\\", "/"));
if (!entry) continue;
entry.cases.push({
name: unescapeXml(/\bname="([^"]*)"/.exec(caseAttrs)?.[1] ?? "(unnamed)"),
message: unescapeXml(/\bmessage="([^"]*)"/.exec(failureAttrs)?.[1] ?? ""),
});
}
} catch {}
if (suites.size && isBuildkite) uploadArtifactsToBuildKite(junitPath);
else rmSync(junitPath, { force: true });
const failed = new Set(); // ran and failed (or hung) — a solo pass is a parallel-mode flake
const incomplete = new Set(); // never finished/started — re-run quietly
let evidence = suites.size > 0;
if (!ok && suites.size) {
for (const t of bucketFiles) {
const suite = suites.get(join("test", t).replaceAll("\\", "/"));
if (suite === undefined) incomplete.add(t);
else if (suite.failures > 0) failed.add(t);
}
} else if (!ok) {
let list = null; // "running" | "not-started" while inside an interrupt report list
for (const line of stripAnsi(stdout).split(/\r?\n/)) {
if (line.startsWith("Interrupted while still running:")) {
list = "running";
continue;
}
if (/^\d+ file\(s\) had not started:$/.test(line)) {
list = "not-started";
continue;
}
if (list && /^ {2}\S/.test(line)) {
const testPath = norm(line);
if (testPath) (list === "running" ? failed : incomplete).add(testPath);
continue;
}
list = null;
const ended = /^✗ (.+?) \((worker crashed|aborted:|no live workers)/.exec(line);
const testPath = ended && norm(ended[1]);
if (testPath) (ended[2] === "worker crashed" ? failed : incomplete).add(testPath);
}
evidence = failed.size + incomplete.size > 0;
}
const rerun = !ok && !evidence ? [...bucketFiles] : [...failed, ...incomplete];
const rerunSet = new Set(rerun);
for (const t of bucketFiles) {
if (rerunSet.has(t)) continue;
const title = join("test", t).replaceAll("\\", "/");
const seconds = suites.get(title)?.seconds;
const timing = seconds === undefined ? "" : ` ${getAnsi("gray")}(${seconds.toFixed(2)}s)${getAnsi("reset")}`;
console.log(`${getAnsi("gray")}[${++i}/${total}]${getAnsi("reset")} ${title}${timing}`);
okResults.push({
testPath: title,
ok: true,
status: "pass",
tests: [],
errors: [],
stdout: "",
stdoutPreview: "",
});
}
for (const t of bucketFiles) {
if (!failed.has(t)) continue;
const title = join("test", t).replaceAll("\\", "/");
const suite = suites.get(title);
if (!suite?.cases.length) continue;
startGroup(
`${title} - ${suite.failures} failing in the parallel batch ${getAnsi("gray")}(${suite.seconds.toFixed(2)}s)${getAnsi("reset")}`,
() => {
for (const { name, message } of suite.cases) {
console.log(`${getAnsi("red")}${getAnsi("reset")} ${name}`);
if (message) console.log(message.replace(/^/gm, " "));
}
},
);
}
if (rerun.length) {
console.log(
`${getAnsi("yellow")}parallel bucket: ${evidence ? `retrying ${failed.size} failed and ${incomplete.size} unfinished file(s)${suites.size ? "" : " (from streamed output; no junit)"}` : `no junit and no streamed evidence, re-running all ${rerun.length} file(s)`} one at a time${getAnsi("reset")}`,
);
for (const testPath of rerun) {
const result = await runOneTest(testPath, false);
if (result?.ok && failed.has(testPath) && isBuildkite) {
const title = join("test", testPath).replaceAll("\\", "/");
const cases = suites.get(title)?.cases ?? [];
const first = cases[0];
const firstError = first ? `${first.name}${first.message.split("\n")[0]}` : "";
const reason = firstError
? escapeHtml(firstError.length > 100 ? `${firstError.slice(0, 97)}…` : firstError)
: "failed in the parallel batch";
const detail = cases.length
? `\n\n\`\`\`terminal\n${cases.map(({ name, message }) => ` ${name}\n${message}`).join("\n\n")}\n\`\`\`\n\n`
: "";
reportAnnotationToBuildKite({
context: "flaky",
label: title,
style: "warning",
content: `<details><summary><a href="${getFileUrl(title)}"><code>${title}</code></a> - ${reason} <i>(in the parallel batch on ${getBuildLabel()}; passed alone)</i></summary>${detail}</details>`,
});
}
}
}
};
const modifiedTests = new Set(
allFiles.filter(filename => filename.startsWith("test/")).map(filename => filename.slice("test/".length)),
);
const isModified = t => modifiedTests.has(t.replaceAll("\\", "/"));
const isBucketCandidate = t =>
parallelism === 1 &&
isTestStrict(t) &&
!isNodeTest(t) &&
!/stress/i.test(t) &&
isParallelAllowlisted(t) &&
!needsDockerService(t) &&
(!validationApplies || (shouldValidateExceptions(t) && shouldValidateLeakSan(t)));
const serialTests = tests.filter(t => !isParallelSafeTest(t));
const parallelSafeTests = tests.filter(t => isParallelSafeTest(t));
const modifiedSerialTests = serialTests.filter(t => isModified(t));
const bucketCandidates = serialTests.filter(t => !isModified(t) && isBucketCandidate(t));
const bucketable = bucketCandidates.length >= 8 ? new Set(bucketCandidates) : new Set();
const restSerialTests = serialTests.filter(t => !isModified(t) && !bucketable.has(t));
await Promise.all(modifiedSerialTests.map(t => limit(() => runOneTest(t, parallelism > 1))));
await Promise.all(restSerialTests.map(t => limit(() => runOneTest(t, parallelism > 1))));
if (bucketable.size) await runParallelBucket(bucketCandidates);
// Concurrent tests log their title without opening a group (interleaved
// output can't nest), so give the phase its own group instead of letting
// the log viewer fold every line under the last serial test's group.
if (parallelSafeTests.length && parallelSafeWidth > 1) {
startGroup(`Running ${parallelSafeTests.length} parallel-safe tests (${parallelSafeWidth}-wide)`);
}
await Promise.all(parallelSafeTests.map(t => parallelSafeLimit(() => runOneTest(t, parallelSafeWidth > 1))));
}
if (vendorTests?.length) {
for (const { cwd: vendorPath, packageManager, testRunner, testPaths } of vendorTests) {
if (!testPaths.length) {
continue;
}
const packageJson = join(relative(cwd, vendorPath), "package.json").replace(/\\/g, "/");
if (packageManager === "bun") {
const { ok } = await runTest(packageJson, () => spawnBunInstall(execPath, { cwd: vendorPath }));
if (!ok) {
continue;
}
} else {
throw new Error(`Unsupported package manager: ${packageManager}`);
}
// build
const buildResult = await spawnBun(execPath, {
cwd: vendorPath,
args: ["run", "build"],
timeout: 60_000,
});
if (!buildResult.ok) {
throw new Error(`Failed to build vendor: ${buildResult.error}`);
}
for (const testPath of testPaths) {
const title = join(relative(cwd, vendorPath), testPath).replace(/\\/g, "/");
if (testRunner === "bun") {
await runTest(title, index =>
spawnBunTest(execPath, testPath, { cwd: vendorPath, env: { TEST_SERIAL_ID: index } }),
);
} else {
const testRunnerPath = join(cwd, "test", "runners", `${testRunner}.ts`);
if (!existsSync(testRunnerPath)) {
throw new Error(`Unsupported test runner: ${testRunner}`);
}
await runTest(title, () =>
spawnBunTest(execPath, testPath, {
cwd: vendorPath,
args: ["--preload", testRunnerPath],
}),
);
}
}
}
}
// tests are all over, close the group from the final test. any further output should print ungrouped.
startGroup("End");
if (isGithubAction) {
reportOutputToGitHubAction("failing_tests_count", failedResults.length);
const markdown = formatTestToMarkdown(failedResults, false, 0);
reportOutputToGitHubAction("failing_tests", markdown);
}
// Generate and upload JUnit reports if requested
if (options["junit"]) {
const junitTempDir = options["junit-temp-dir"];
mkdirSync(junitTempDir, { recursive: true });
// Generate JUnit reports for tests that don't use bun test
const nonBunTestResults = [...okResults, ...flakyResults, ...failedResults].filter(result => {
// Check if this is a test that wasn't run with bun test
const isNodeTest =
isJavaScript(result.testPath) && !isTestStrict(result.testPath) && !result.testPath.includes("vendor");
return isNodeTest;
});
// If we have tests not covered by bun test JUnit reports, generate a report for them
if (nonBunTestResults.length > 0) {
const nonBunTestJunitPath = join(junitTempDir, "non-bun-test-results.xml");
generateJUnitReport(nonBunTestJunitPath, nonBunTestResults);
!isQuiet &&
console.log(
`Generated JUnit report for ${nonBunTestResults.length} non-bun test results at ${nonBunTestJunitPath}`,
);
// Upload this report immediately if we're on BuildKite
if (isBuildkite && options["junit-upload"]) {
const uploadSuccess = await uploadJUnitToBuildKite(nonBunTestJunitPath);
if (uploadSuccess) {
// Delete the file after successful upload to prevent redundant uploads
try {
unlinkSync(nonBunTestJunitPath);
!isQuiet && console.log(`Uploaded and deleted non-bun test JUnit report`);
} catch (unlinkError) {
!isQuiet && console.log(`Uploaded but failed to delete non-bun test JUnit report: ${unlinkError.message}`);
}
} else {
!isQuiet && console.log(`Failed to upload non-bun test JUnit report to BuildKite`);
}
}
}
// Check for any JUnit reports that may not have been uploaded yet
// Since we're deleting files after upload, any remaining files need to be uploaded
if (isBuildkite && options["junit-upload"]) {
try {
// Only process XML files and skip the non-bun test results which we've already uploaded
const allJunitFiles = readdirSync(junitTempDir).filter(
file => file.endsWith(".xml") && file !== "non-bun-test-results.xml",
);
if (allJunitFiles.length > 0) {
!isQuiet && console.log(`Found ${allJunitFiles.length} remaining JUnit reports to upload...`);
// Process each remaining JUnit file - these are files we haven't processed yet
let uploadedCount = 0;
for (const file of allJunitFiles) {
const filePath = join(junitTempDir, file);
if (existsSync(filePath)) {
try {
const uploadSuccess = await uploadJUnitToBuildKite(filePath);
if (uploadSuccess) {
// Delete the file after successful upload
try {
unlinkSync(filePath);
uploadedCount++;
} catch (unlinkError) {
!isQuiet && console.log(`Uploaded but failed to delete ${file}: ${unlinkError.message}`);
}
}
} catch (err) {
console.error(`Error uploading JUnit file ${file}:`, err);
}
}
}
if (uploadedCount > 0) {
!isQuiet && console.log(`Uploaded and deleted ${uploadedCount} remaining JUnit reports`);
} else {
!isQuiet && console.log(`No JUnit reports needed to be uploaded`);
}
} else {
!isQuiet && console.log(`No remaining JUnit reports found to upload`);
}
} catch (err) {
console.error(`Error checking for remaining JUnit reports:`, err);
}
}
}
if (options["coredump-upload"]) {
try {
const coresDirBase = dirname(coresDir);
const coresDirName = basename(coresDir);
const coreFileNames = readdirSync(coresDir);
if (coreFileNames.length > 0) {
console.log(`found ${coreFileNames.length} cores in ${coresDir}`);
let totalBytes = 0;
let totalBlocks = 0;
for (const f of coreFileNames) {
const stat = statSync(join(coresDir, f));
totalBytes += stat.size;
totalBlocks += stat.blocks;
}
console.log(`total apparent size = ${totalBytes} bytes`);
console.log(`total size on disk = ${512 * totalBlocks} bytes`);
const outdir = mkdtempSync(join(tmpdir(), "cores-upload"));
const outfileName = `${coresDirName}.tar.gz.age`;
const outfileAbs = join(outdir, outfileName);
// This matches an age identity known by Bun employees. Core dumps from CI have to be kept
// secret since they will contain API keys.
const ageRecipient = "age1eunsrgxwjjpzr48hm0y98cw2vn5zefjagt4r0qj4503jg2nxedqqkmz6fu"; // reject external PRs changing this, see above
// Run tar in the parent directory of coresDir so that it creates archive entries with
// coresDirName in them. This way when you extract the tarball you get a folder named
// bun-cores-XYZ containing core files, instead of a bunch of core files strewn in your
// current directory
const before = Date.now();
const zipAndEncrypt = await spawnSafe({
command: "bash",
args: [
"-c",
// tar -S: handle sparse files efficiently
`set -euo pipefail && tar -Sc "$0" | gzip -1 | age -e -r ${ageRecipient} -o "$1"`,
// $0
coresDirName,
// $1
outfileAbs,
],
cwd: coresDirBase,
stdout: () => {},
timeout: 60_000,
});
const elapsed = Date.now() - before;
if (!zipAndEncrypt.ok) {
throw new Error(zipAndEncrypt.error);
}
console.log(`saved core dumps to ${outfileAbs} (${statSync(outfileAbs).size} bytes) in ${elapsed} ms`);
await uploadArtifact(outfileAbs);
} else {
console.log(`no cores found in ${coresDir}`);
}
} catch (err) {
console.error("Error collecting and uploading core dumps:", err);
}
}
if (!isCI && !isQuiet) {
console.table({
"Total Tests": okResults.length + failedResults.length + flakyResults.length,
"Passed Tests": okResults.length,
"Failing Tests": failedResults.length,
"Flaky Tests": flakyResults.length,
});
if (failedResults.length) {
console.log(`${getAnsi("red")}Failing Tests:${getAnsi("reset")}`);
for (const testPath of failedResultsTitles) {
console.log(`${getAnsi("red")}- ${testPath}${getAnsi("reset")}`);
}
}
if (flakyResults.length) {
console.log(`${getAnsi("yellow")}Flaky Tests:${getAnsi("reset")}`);
for (const testPath of flakyResultsTitles) {
console.log(`${getAnsi("yellow")}- ${testPath}${getAnsi("reset")}`);
}
}
}
// Dump per-file results as JSON for post-processing (test-fix workflows
// shard from this). Opt-in via --results-json so CI output is unchanged.
if (cliOptions["results-json"]) {
const all = [...okResults, ...flakyResults, ...failedResults].map(r => ({
testPath: r.testPath,
ok: r.ok,
status: r.status,
error: r.error,
exitCode: r.exitCode,
signalCode: r.signalCode,
duration: r.duration,
stdoutPreview: r.stdoutPreview?.slice?.(-4000),
}));
writeFileSync(cliOptions["results-json"], JSON.stringify(all, null, 2));
!isQuiet && console.log(`Wrote ${all.length} results to ${cliOptions["results-json"]}`);
}
// Exclude flaky tests from the final results
return [...okResults, ...failedResults];
}
/**
* @typedef {object} SpawnOptions
* @property {string} command
* @property {string[]} [args]
* @property {string} [cwd]
* @property {number} [timeout]
* @property {object} [env]
* @property {function} [stdout]
* @property {function} [stderr]
*/
/**
* @typedef {object} SpawnResult
* @property {boolean} ok
* @property {string} [error]
* @property {Error} [spawnError]
* @property {number} [exitCode]
* @property {number} [signalCode]
* @property {number} timestamp
* @property {number} duration
* @property {string} stdout
* @property {number} [pid]
*/
/**
* @param {SpawnOptions} options
* @returns {Promise<SpawnResult>}
*/
async function spawnSafe(options) {
const {
command,
args,
cwd,
env,
timeout = spawnTimeout,
stdout = process.stdout.write.bind(process.stdout),
stderr = process.stderr.write.bind(process.stderr),
retries = 0,
} = options;
let exitCode;
let signalCode;
let spawnError;
let timestamp;
let timedOut = false;
let idledOut = false;
let duration;
let subprocess;
let timer;
let idleTimer;
let armIdleTimer;
let buffer = "";
let doneCalls = 0;
const beforeDone = resolve => {
// TODO: wait for stderr as well, spawn.test currently causes it to hang
if (doneCalls++ === 1) {
done(resolve);
}
};
const done = resolve => {
if (timer) {
clearTimeout(timer);
}
clearTimeout(idleTimer);
subprocess.stderr.unref();
subprocess.stdout.unref();
subprocess.unref();
if (!signalCode && exitCode === undefined) {
subprocess.stdout.destroy();
subprocess.stderr.destroy();
if (!subprocess.killed) {
subprocess.kill(9);
}
}
resolve();
};
await new Promise(resolve => {
try {
function unsafeBashEscape(str) {
if (!str) return "";
if (str.includes(" ")) return JSON.stringify(str);
return str;
}
if (process.env.SHOW_SPAWN_COMMANDS) {
console.log(
"SPAWNING COMMAND:\n" +
[
"echo -n | " +
Object.entries(env)
.map(([key, value]) => `${unsafeBashEscape(key)}=${unsafeBashEscape(value)}`)
.join(" "),
unsafeBashEscape(command),
...args.map(unsafeBashEscape),
].join(" ") +
" | cat",
);
}
subprocess = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
timeout: options.gracefulTimeout ? undefined : timeout,
cwd,
env,
});
subprocess.on("spawn", () => {
timestamp = Date.now();
const expire = () => {
timedOut = true;
clearTimeout(idleTimer);
if (options.gracefulTimeout && !isWindows) {
subprocess.kill("SIGTERM");
timer = setTimeout(() => {
subprocess.kill(9);
done(resolve);
}, 15_000);
return;
}
done(resolve);
};
timer = setTimeout(expire, timeout);
if (options.idleTimeout) {
armIdleTimer = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
idledOut = true;
expire();
}, options.idleTimeout);
};
armIdleTimer();
}
});
subprocess.on("error", error => {
spawnError = error;
done(resolve);
});
subprocess.on("exit", (code, signal) => {
duration = Date.now() - timestamp;
exitCode = code;
signalCode = signal;
if (signalCode || exitCode !== 0) {
beforeDone(resolve);
} else {
done(resolve);
}
});
subprocess.stdout.on("end", () => {
beforeDone(resolve);
});
subprocess.stdout.on("data", chunk => {
armIdleTimer?.();
const text = chunk.toString("utf-8");
stdout?.(text);
buffer += text;
});
subprocess.stderr.on("data", chunk => {
armIdleTimer?.();
const text = chunk.toString("utf-8");
stderr?.(text);
buffer += text;
});
} catch (error) {
spawnError = error;
resolve();
}
});
if (spawnError && retries < 5) {
const { code } = spawnError;
if (code === "EBUSY" || code === "UNKNOWN") {
await new Promise(resolve => setTimeout(resolve, 1000 * (retries + 1)));
return spawnSafe({
...options,
retries: retries + 1,
});
}
}
let error;
if (exitCode === 0) {
// ...
} else if (spawnError) {
const { stack, message } = spawnError;
if (/timed? ?out/.test(message)) {
error = "timeout";
} else {
error = "spawn error";
buffer = stack || message;
}
} else if ((error = buffer.match(/ERROR: Unchecked JS exception:/g))) {
// Format from JSC's VM::verifyExceptionCheckNeedIsSatisfied / ExceptionEventLocation::printInternal:
// "{functionName} @ {__FILE__}:{line}"
// __FILE__ is relative to the cmake build dir (e.g. ../../src/...) — strip leading ../ for a repo-relative path.
// JSC crashes immediately after printing, so >1 block means multiple subprocesses crashed.
const count = error.length;
const repoRel = p => p?.replace(/^(?:\.\.?[\\/])+/, "");
const thrown = /This scope can throw a JS exception: (\S+) @ (\S+)/.exec(buffer);
const unchecked = /But the exception was unchecked as of this scope: (\S+) @ (\S+)/.exec(buffer);
error = "unchecked exception";
if (unchecked) {
error += ` at ${unchecked[1]} @ ${repoRel(unchecked[2])}`;
if (thrown) error += ` (thrown from ${repoRel(thrown[2])})`;
} else if (thrown) {
error += ` thrown from ${thrown[1]} @ ${repoRel(thrown[2])}`;
}
if (count > 1) error += ` +${count - 1} more`;
} else if (/LeakSanitizer: detected memory leaks/.test(buffer)) {
error = "leak";
const leak =
/(Direct|Indirect) leak of (\d+) byte\(s\) in (\d+) object\(s\) allocated from:\s*\n((?:\s*#\d+[^\n]*\n)+)/.exec(
buffer,
);
if (leak) {
const [, kind, bytes, objects, stack] = leak;
error = `${kind.toLowerCase()} leak of ${bytes}b${objects === "1" ? "" : ` in ${objects} objects`}`;
const frames = stack
.split("\n")
.map(line => /#\d+ 0x[0-9a-f]+ in ([^\n]+)/i.exec(line)?.[1]?.trim())
.filter(Boolean);
const isAlloc = f =>
/^(?:__interceptor_|__sanitizer|_?malloc\b|_?malloc_zone|calloc|realloc|posix_memalign|operator new|mi_|bmalloc|bun_alloc|WTF::fast(?:Zeroed)?Malloc|WTF::Malloc)/i.test(
f,
);
const ownTree = f => {
const loc = /(\S+):\d+$/.exec(f)?.[1];
return !!loc && loc.replace(/^(?:\.\.?[\\/])+/, "").startsWith("src/");
};
const where = frames.find(f => !isAlloc(f) && ownTree(f)) ?? frames.find(f => !isAlloc(f));
if (where) {
const [, symbol, loc] = /^(.*?)\s+(\S+:\d+)$/.exec(where) ?? [null, where, ""];
const file = loc.replace(/^(?:\.\.?[\\/])+/, "");
error += ` in ${symbol.replace(/\(.*$/, "")}${file ? ` (${file})` : ""}`.slice(0, 160);
}
}
const leaks = buffer.match(/(?:Direct|Indirect) leak of/g)?.length ?? 1;
if (leaks > 1) error += ` +${leaks - 1} more`;
} else if (
(error = /thread \d+ panic: (.*)(?:\r\n|\r|\n|\\n)/i.exec(buffer)) ||
(error = /panic\(.*\): (.*)(?:\r\n|\r|\n|\\n)/i.exec(buffer)) ||
(error = /(Segmentation fault) at address/i.exec(buffer)) ||
(error = /(Internal assertion failure)/i.exec(buffer)) ||
(error = /(Illegal instruction) at address/i.exec(buffer)) ||
(error = /panic: (.*) at address/i.exec(buffer)) ||
(error = /oh no: Bun has crashed/i.exec(buffer)) ||
(error = /(ERROR: AddressSanitizer)/.exec(buffer)) ||
(error = /(SIGABRT)/.exec(buffer))
) {
const [, message] = error || [];
error = message ? message.split("\n")[0].toLowerCase() : "crash";
error = error.indexOf("\\n") !== -1 ? error.substring(0, error.indexOf("\\n")) : error;
error = `pid ${subprocess.pid} ${error}`;
} else if (signalCode) {
if (signalCode === "SIGTERM" && duration >= timeout) {
error = "timeout";
} else {
error = signalCode;
}
} else if (exitCode === 1) {
const match = buffer.match(/\x1b\[31m\s(\d+) fail/);
if (match) {
error = `${match[1]} failing`;
} else {
error = "code 1";
}
const lines = stripAnsi(buffer).split(/\r?\n/);
const failAt = lines.findIndex(line => /^\(fail\) /.test(line.trim()));
if (failAt === -1) {
const at = lines.findIndex(
(line, k) => k > 0 && /^\s+at /.test(line) && lines[k - 1].trim() && !/^\s+at /.test(lines[k - 1]),
);
if (at !== -1) {
const message = lines[at - 1].trim();
error += `: ${message.length > 100 ? `${message.slice(0, 97)}…` : message}`;
}
} else {
const name = lines[failAt]
.trim()
.replace(/^\(fail\) /, "")
.replace(/ \[[\d.]+m?s\]$/, "");
let reason = "";
for (let k = failAt - 1; k >= Math.max(0, failAt - 40); k--) {
const m = /^\s*error: (.+)$/.exec(lines[k]);
if (m) {
reason = m[1].trim();
break;
}
}
const first = reason ? `${name}${reason}` : name;
error += `: ${first.length > 100 ? `${first.slice(0, 97)}…` : first}`;
}
} else if (exitCode === undefined) {
error = "timeout";
} else if (exitCode !== 0) {
if (isWindows) {
const winCode = getWindowsExitReason(exitCode);
if (winCode) {
exitCode = winCode;
}
}
error = `code ${exitCode}`;
}
if (timedOut && (!error || error === signalCode || /^code \d+$/.test(error)))
error = idledOut ? "stalled" : "timeout";
return {
ok: exitCode === 0 && !signalCode && !spawnError,
error,
exitCode,
signalCode,
spawnError,
stdout: buffer,
timestamp: timestamp || Date.now(),
duration: duration || 0,
pid: subprocess?.pid,
};
}
let _combinedPath = "";
function getCombinedPath(execPath) {
if (!_combinedPath) {
_combinedPath = addPath(realpathSync(dirname(execPath)), process.env.PATH);
// If we're running bun-profile.exe, try to make a symlink to bun.exe so
// that anything looking for "bun" will find it
if (isCI && basename(execPath, extname(execPath)).toLowerCase() !== "bun") {
const existingPath = execPath;
const newPath = join(dirname(execPath), "bun" + extname(execPath));
try {
// On Windows, we might run into permissions issues with symlinks.
// If that happens, fall back to a regular hardlink.
symlinkSync(existingPath, newPath, "file");
} catch (error) {
try {
linkSync(existingPath, newPath);
} catch (error) {
console.warn(`Failed to link bun`, error);
}
}
}
}
return _combinedPath;
}
/**
* @typedef {object} SpawnBunResult
* @extends SpawnResult
* @property {string} [crashes]
*/
/**
* @param {string} execPath Path to bun binary
* @param {SpawnOptions} options
* @returns {Promise<SpawnBunResult>}
*/
async function spawnBun(execPath, { args, cwd, timeout, gracefulTimeout, idleTimeout, env, stdout, stderr }) {
const path = getCombinedPath(execPath);
const tmpdirPath = mkdtempSync(join(tmpdir(), "buntmp-"));
const { username, homedir } = userInfo();
const shellPath = getShell();
const bunEnv = {
...process.env,
PATH: path,
TMPDIR: tmpdirPath,
BUN_TMPDIR: tmpdirPath,
USER: username,
USERNAME: isWindows ? username : undefined, // %USERNAME% for ported Windows tests
HOME: homedir,
SHELL: shellPath,
FORCE_COLOR: "1",
BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1",
BUN_DEBUG_QUIET_LOGS: "1",
BUN_DISABLE_SLOW_FILESYSTEM_WARNING: "1",
BUN_GARBAGE_COLLECTOR_LEVEL: "1",
BUN_JSC_randomIntegrityAuditRate: "1.0",
BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0",
BUN_INSTALL_CACHE_DIR: tmpdirPath,
SHELLOPTS: isWindows ? "igncr" : undefined, // ignore "\r" on Windows
TEST_TMPDIR: tmpdirPath, // Used in Node.js tests.
...(typeof remapPort == "number"
? { BUN_CRASH_REPORT_URL: `http://localhost:${remapPort}` }
: { BUN_ENABLE_CRASH_REPORTING: "0" }),
};
if (isWindows && bunEnv.Path) {
delete bunEnv.Path;
}
if (env) {
Object.assign(bunEnv, env);
}
if (isWindows) {
delete bunEnv["PATH"];
bunEnv["Path"] = path;
for (const tmpdir of ["TMPDIR", "TEMP", "TEMPDIR", "TMP"]) {
delete bunEnv[tmpdir];
}
bunEnv["TEMP"] = tmpdirPath;
}
if (timeout === undefined) {
timeout = spawnBunTimeout;
}
try {
const existingCores = options["coredump-upload"] ? readdirSync(coresDir) : [];
const result = await spawnSafe({
command: execPath,
args,
cwd,
timeout,
gracefulTimeout,
idleTimeout,
env: bunEnv,
stdout,
stderr,
});
const newCores = options["coredump-upload"] ? readdirSync(coresDir).filter(c => !existingCores.includes(c)) : [];
let crashes = "";
if (options["coredump-upload"] && (result.signalCode !== null || newCores.length > 0)) {
// warn if the main PID crashed and we don't have a core
if (result.signalCode !== null && !newCores.some(c => c.endsWith(`${result.pid}.core`))) {
crashes += `main process killed by ${result.signalCode} but no core file found\n`;
}
if (newCores.length > 0) {
result.ok = false;
if (!isAlwaysFailure(result.error)) result.error = "core dumped";
}
for (const coreName of newCores) {
const corePath = join(coresDir, coreName);
let out = "";
const gdb = await spawnSafe({
command: "gdb",
args: ["-batch", `--eval-command=bt`, "--core", corePath, execPath],
timeout: 240_000,
stderr: () => {},
stdout(text) {
out += text;
},
});
if (!gdb.ok) {
crashes += `failed to get backtrace from GDB: ${gdb.error}\n`;
} else {
crashes += `======== Stack trace from GDB for ${coreName}: ========\n`;
for (const line of out.split("\n")) {
// filter GDB output since it is pretty verbose
if (
line.startsWith("Program terminated") ||
line.startsWith("#") || // gdb backtrace lines start with #0, #1, etc.
line.startsWith("[Current thread is")
) {
crashes += line + "\n";
}
}
}
}
}
// Skip this if the remap server didn't work or if Bun exited normally
// (tests in which a subprocess crashed should at least set exit code 1)
if (typeof remapPort == "number" && result.exitCode !== 0) {
try {
// When Bun crashes, it exits before the subcommand it runs to upload the crash report has necessarily finished.
// So wait a little bit to make sure that the crash report has at least started uploading
// (once the server sees the /ack request then /traces will wait for any crashes to finish processing)
// There is a bug that if a test causes crash reports but exits with code 0, the crash reports will instead
// be attributed to the next test that fails. I'm not sure how to fix this without adding a sleep in between
// all tests (which would slow down CI a lot).
await setTimeoutPromise(500);
const response = await fetch(`http://localhost:${remapPort}/traces`);
if (!response.ok || response.status !== 200) throw new Error(`server responded with code ${response.status}`);
const traces = await response.json();
if (traces.length > 0) {
result.ok = false;
if (!isAlwaysFailure(result.error)) result.error = "crash reported";
crashes += `${traces.length} crashes reported during this test\n`;
for (const t of traces) {
if (t.failed_parse) {
crashes += "Trace string failed to parse:\n";
crashes += t.failed_parse + "\n";
} else if (t.failed_remap) {
crashes += "Parsed trace failed to remap:\n";
crashes += JSON.stringify(t.failed_remap, null, 2) + "\n";
} else {
crashes += "================\n";
crashes += t.remap + "\n";
}
}
}
} catch (e) {
crashes += "failed to fetch traces: " + e.toString() + "\n";
}
}
if (crashes.length > 0) result.crashes = crashes;
return result;
} finally {
try {
rmSync(tmpdirPath, { recursive: true, force: true });
} catch (error) {
console.warn(error);
}
}
}
/**
* @typedef {object} TestResult
* @property {string} testPath
* @property {boolean} ok
* @property {string} status
* @property {string} [error]
* @property {TestEntry[]} tests
* @property {string} stdout
* @property {string} stdoutPreview
*/
/**
* @typedef {object} TestEntry
* @property {string} [url]
* @property {string} file
* @property {string} test
* @property {string} status
* @property {TestError} [error]
* @property {number} [duration]
*/
/**
* @typedef {object} TestError
* @property {string} [url]
* @property {string} file
* @property {number} line
* @property {number} col
* @property {string} name
* @property {string} stack
*/
/**
* @param {string} execPath
* @param {string} testPath
* @param {object} [opts]
* @param {string} [opts.cwd]
* @param {string[]} [opts.args]
* @param {object} [opts.env]
* @returns {Promise<TestResult>}
*/
async function spawnBunTest(execPath, testPath, opts = { cwd }) {
const timeout = getTestTimeout(testPath);
const isAsan = basename(execPath).includes("asan");
const perTestTimeout = Math.ceil(timeout / 2) * (isAsan ? 3 : 1);
const absPath = join(opts["cwd"], testPath);
const isReallyTest = isTestStrict(testPath) || absPath.includes("vendor");
const args = opts["args"] ?? [];
const testArgs = ["test", ...args, `--timeout=${perTestTimeout}`, "--reporter=dots"];
// This will be set if a JUnit file is generated
let junitFilePath = null;
// In CI, we want to use JUnit for all tests
// Create a unique filename for each test run using a hash of the test path
// This ensures we can run tests in parallel without file conflicts
if (cliOptions.junit) {
const testHash = createHash("sha1").update(testPath).digest("base64url");
const junitTempDir = cliOptions["junit-temp-dir"];
// Create the JUnit file path
junitFilePath = `${junitTempDir}/test-${testHash}.xml`;
// Add JUnit reporter
testArgs.push("--reporter=junit");
testArgs.push(`--reporter-outfile=${junitFilePath}`);
}
testArgs.push(absPath);
const env = {
GITHUB_ACTIONS: "true", // always true so annotations are parsed
...opts["env"],
};
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateExceptions(relative(cwd, absPath))) {
env.BUN_JSC_validateExceptionChecks = "1";
env.BUN_JSC_dumpSimulatedThrows = "1";
}
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateLeakSan(relative(cwd, absPath))) {
env.BUN_DESTRUCT_VM_ON_EXIT = "1";
env.ASAN_OPTIONS = "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1";
// prettier-ignore
env.LSAN_OPTIONS = `malloc_context_size=30:print_suppressions=0:suppressions=${process.cwd()}/test/leaksan.supp`;
}
if (basename(execPath).includes("asan")) {
// ASAN test processes are slow and memory-heavy; if the bun test runner is
// SIGKILLed (timeout, OOM) its spawned subprocesses keep running and pile
// up, eventually OOM-killing the agent. --no-orphans makes every spawned
// bun exit when its parent dies AND SIGKILL its own descendants on exit.
env.BUN_FEATURE_FLAG_NO_ORPHANS = "1";
}
const { ok, error, stdout, crashes } = await spawnBun(execPath, {
args: isReallyTest ? testArgs : [...args, absPath],
cwd: opts["cwd"],
// release-asan with debug-assertions on runs every spawned subprocess
// slower; give each file more headroom so tests with heavy beforeAll
// setup (napi node-gyp compiles) or many subprocess spawns don't hit
// the file wall before any individual test times out. Kept below the
// per-test multiplier so the overall shard stays inside the job timeout.
timeout: isReallyTest ? Math.ceil(timeout * (isAsan ? 2 : 1)) : 30_000,
env,
stdout: options.stdout,
stderr: options.stderr,
});
let { tests, errors, stdout: stdoutPreview } = parseTestStdout(stdout, testPath);
if (crashes) stdoutPreview += crashes;
// If we generated a JUnit file and we're on BuildKite, upload it immediately
if (junitFilePath && isReallyTest && isBuildkite && cliOptions["junit-upload"]) {
// Give the file system a moment to finish writing the file
if (existsSync(junitFilePath)) {
addToJunitUploadQueue(junitFilePath);
}
}
return {
testPath,
ok,
status: ok ? "pass" : "fail",
error,
errors,
tests,
stdout,
stdoutPreview,
};
}
/**
* @param {string} testPath
* @returns {number}
*/
function getTestTimeout(testPath) {
if (
/integration|3rd_party|docker|bun-install-registry|bun-security-scanner-matrix|v8|bundler_compile|tonic|test[\\/]napi/i.test(
testPath,
)
) {
return integrationTimeout;
}
return testTimeout;
}
/**
* @param {NodeJS.WritableStream} io
* @param {string} chunk
*/
function pipeTestStdout(io, chunk) {
if (isGithubAction) {
io.write(chunk.replace(/\:\:(?:end)?group\:\:.*(?:\r\n|\r|\n)/gim, ""));
} else if (isBuildkite) {
io.write(chunk.replace(/(?:---|\+\+\+|~~~|\^\^\^) /gim, " ").replace(/\:\:.*(?:\r\n|\r|\n)/gim, ""));
} else {
io.write(chunk.replace(/\:\:.*(?:\r\n|\r|\n)/gim, ""));
}
}
/**
* @typedef {object} TestOutput
* @property {string} stdout
* @property {TestResult[]} tests
* @property {TestError[]} errors
*/
/**
* @param {string} stdout
* @param {string} [testPath]
* @returns {TestOutput}
*/
function parseTestStdout(stdout, testPath) {
const tests = [];
const errors = [];
let lines = [];
let skipCount = 0;
let testErrors = [];
let done;
for (const chunk of stdout.split("\n")) {
const string = stripAnsi(chunk);
if (!string.startsWith("::")) {
lines.push(chunk);
if (string.startsWith("✓") || string.startsWith("»") || string.startsWith("✎")) {
skipCount++;
} else {
// If there are more than 3 consecutive non-failing tests,
// omit the non-failing tests between them.
if (skipCount > 3) {
const removeStart = lines.length - skipCount;
const removeCount = skipCount - 2;
const omitLine = `${getAnsi("gray")}... omitted ${removeCount} tests ...${getAnsi("reset")}`;
lines.splice(removeStart, removeCount, omitLine);
}
skipCount = 0;
}
}
// Once the summary is printed, exit early so tests aren't double counted.
// This needs to be changed if multiple files are run in a single test run.
if (done || string.startsWith("::endgroup")) {
done ||= true;
continue;
}
if (string.startsWith("::error")) {
const eol = string.indexOf("::", 8);
const message = unescapeGitHubAction(string.substring(eol + 2));
const { file, line, col, title } = Object.fromEntries(
string
.substring(8, eol)
.split(",")
.map(entry => entry.split("=")),
);
const errorPath = file || testPath;
const error = {
url: getFileUrl(errorPath, line),
file: errorPath,
line,
col,
name: title,
stack: `${title}\n${message}`,
};
errors.push(error);
testErrors.push(error);
continue;
}
for (const { emoji, text } of [
{ emoji: "✓", text: "pass" },
{ emoji: "✗", text: "fail" },
{ emoji: "»", text: "skip" },
{ emoji: "✎", text: "todo" },
]) {
if (!string.startsWith(emoji)) {
continue;
}
const eol = string.lastIndexOf(" [") || undefined;
const test = string.substring(1 + emoji.length, eol);
const duration = eol ? string.substring(eol + 2, string.lastIndexOf("]")) : undefined;
tests.push({
url: getFileUrl(testPath),
file: testPath,
test,
status: text,
errors: testErrors,
duration: parseDuration(duration),
});
for (let error of testErrors) {
error.test = test;
}
testErrors = [];
}
}
let preview;
const removeCount = lines.length - 100;
if (removeCount > 10) {
const omitLine = `${getAnsi("gray")}... omitted ${removeCount} lines ...${getAnsi("reset")}\n`;
preview = [omitLine, ...lines.slice(-100)].join("\n");
} else {
preview = lines.join("\n");
}
return {
tests,
errors,
stdout: preview,
};
}
/**
* @param {string} execPath
* @param {SpawnOptions} options
* @returns {Promise<TestResult>}
*/
async function spawnBunInstall(execPath, options) {
// spawnBun sets BUN_INSTALL_CACHE_DIR to a fresh tmpdir so per-test installs
// are hermetic. This function only runs the runner's own dependency setup
// (root, test/, vendor), which should hit the image's baked cache when one
// exists (bootstrap.{sh,ps1} set BUN_INSTALL_CACHE_DIR machine-wide).
const cacheDir = process.env.BUN_INSTALL_CACHE_DIR;
let { ok, error, stdout, duration, crashes } = await spawnBun(execPath, {
args: ["install"],
timeout: testTimeout,
...options,
env: {
// A puppeteer postinstall reached through these installs would download
// Chrome into the agent's shared ~/.cache; a half-extracted download left
// there on a persistent agent fails every later job's install. Tests that
// need a browser install one into a per-run cache (getPuppeteerInstallEnv).
PUPPETEER_SKIP_DOWNLOAD: "1",
...options.env,
...(cacheDir && { BUN_INSTALL_CACHE_DIR: cacheDir }),
},
});
if (crashes) stdout += crashes;
const relativePath = relative(cwd, options.cwd);
const testPath = join(relativePath, "package.json");
const status = ok ? "pass" : "fail";
return {
testPath,
ok,
status,
error,
errors: [],
tests: [
{
file: testPath,
test: "bun install",
status,
duration: parseDuration(duration),
},
],
stdout,
stdoutPreview: stdout,
};
}
/**
* @param {string} path
* @returns {boolean}
*/
function isJavaScript(path) {
return /\.(c|m)?(j|t)sx?$/.test(basename(path));
}
/**
* @param {string} path
* @returns {boolean}
*/
function isJavaScriptTest(path) {
return isJavaScript(path) && /\.test|spec\./.test(basename(path));
}
/**
* @param {string} path
* @returns {boolean}
*/
function isNodeTest(path) {
// Do not run node tests on macOS x64 in CI, those machines are slow and expensive.
if (isCI && isMacOS && isX64) {
return false;
}
if (!isJavaScript(path)) {
return false;
}
const unixPath = path.replaceAll(sep, "/");
return (
unixPath.includes("js/node/test/parallel/") ||
unixPath.includes("js/node/test/sequential/") ||
unixPath.includes("js/bun/test/parallel/")
);
}
/**
* @param {string} path
* @returns {boolean}
*/
function isClusterTest(path) {
const unixPath = path.replaceAll(sep, "/");
return unixPath.includes("js/node/cluster/test-") && unixPath.endsWith(".ts");
}
/**
* @param {string} path
* @returns {boolean}
*/
function isTest(path) {
return isNodeTest(path) || isClusterTest(path) ? true : isTestStrict(path);
}
/**
* @param {string} path
* @returns {boolean}
*/
function isTestStrict(path) {
return isJavaScript(path) && /\.test|spec\./.test(basename(path));
}
/**
* @param {string} path
* @returns {boolean}
*/
function isHidden(path) {
return /node_modules|node.js/.test(dirname(path)) || /^\./.test(basename(path));
}
/**
* @param {string} cwd
* @returns {string[]}
*/
function getTests(cwd) {
function* getFiles(cwd, path) {
const dirname = join(cwd, path);
for (const entry of readdirSync(dirname, { encoding: "utf-8", withFileTypes: true })) {
const { name } = entry;
const filename = join(path, name);
if (isHidden(filename)) {
continue;
}
if (entry.isFile()) {
if (isTest(filename)) {
yield filename;
}
} else if (entry.isDirectory()) {
yield* getFiles(cwd, filename);
}
}
}
return [...getFiles(cwd, "")].sort();
}
/**
* @typedef {object} Vendor
* @property {string} package
* @property {string} repository
* @property {string} tag
* @property {string} [packageManager]
* @property {string} [testPath]
* @property {string} [testRunner]
* @property {string[]} [testExtensions]
* @property {boolean | Record<string, boolean | string>} [skipTests]
*/
/**
* @typedef {object} VendorTest
* @property {string} cwd
* @property {string} packageManager
* @property {string} testRunner
* @property {string[]} testPaths
*/
/**
* @param {string} cwd
* @returns {Promise<VendorTest[]>}
*/
async function getVendorTests(cwd) {
const vendorPath = join(cwd, "test", "vendor.json");
if (!existsSync(vendorPath)) {
throw new Error(`Did not find vendor.json: ${vendorPath}`);
}
/** @type {Vendor[]} */
const vendors = JSON.parse(readFileSync(vendorPath, "utf-8")).sort(
(a, b) => a.package.localeCompare(b.package) || a.tag.localeCompare(b.tag),
);
const shardId = parseInt(options["shard"]);
const maxShards = parseInt(options["max-shards"]);
/** @type {Vendor[]} */
let relevantVendors = [];
if (maxShards > 1) {
for (let i = 0; i < vendors.length; i++) {
if (i % maxShards === shardId) {
relevantVendors.push(vendors[i]);
}
}
} else {
relevantVendors = vendors.flat();
}
return Promise.all(
relevantVendors.map(
async ({ package: name, repository, tag, testPath, testExtensions, testRunner, packageManager, skipTests }) => {
const vendorPath = join(cwd, "vendor", name);
if (!existsSync(vendorPath)) {
const { ok, error } = await spawnSafe({
command: "git",
args: ["clone", "--depth", "1", "--single-branch", repository, vendorPath],
timeout: testTimeout,
cwd,
});
if (!ok) throw new Error(`failed to git clone vendor '${name}': ${error}`);
}
let { ok, error } = await spawnSafe({
command: "git",
args: ["fetch", "--depth", "1", "origin", "tag", tag],
timeout: testTimeout,
cwd: vendorPath,
});
if (!ok) throw new Error(`failed to fetch tag ${tag} for vendor '${name}': ${error}`);
({ ok, error } = await spawnSafe({
command: "git",
args: ["checkout", tag],
timeout: testTimeout,
cwd: vendorPath,
}));
if (!ok) throw new Error(`failed to checkout tag ${tag} for vendor '${name}': ${error}`);
const packageJsonPath = join(vendorPath, "package.json");
if (!existsSync(packageJsonPath)) {
throw new Error(`Vendor '${name}' does not have a package.json: ${packageJsonPath}`);
}
const testPathPrefix = testPath || "test";
const testParentPath = join(vendorPath, testPathPrefix);
if (!existsSync(testParentPath)) {
throw new Error(`Vendor '${name}' does not have a test directory: ${testParentPath}`);
}
const isTest = path => {
if (!isJavaScriptTest(path)) {
return false;
}
if (typeof skipTests === "boolean") {
return !skipTests;
}
if (typeof skipTests === "object") {
for (const [glob, reason] of Object.entries(skipTests)) {
const pattern = new RegExp(`^${glob.replace(/\*/g, ".*")}$`);
if (pattern.test(path) && reason) {
return false;
}
}
}
return true;
};
const testPaths = readdirSync(testParentPath, { encoding: "utf-8", recursive: true })
.filter(filename =>
testExtensions ? testExtensions.some(ext => filename.endsWith(`.${ext}`)) : isTest(filename),
)
.map(filename => join(testPathPrefix, filename))
.filter(
filename =>
!filters?.length ||
filters.some(filter => join(vendorPath, filename).replace(/\\/g, "/").includes(filter)),
);
return {
cwd: vendorPath,
packageManager: packageManager || "bun",
testRunner: testRunner || "bun",
testPaths,
};
},
),
);
}
/**
* Checked-in median wall-clock duration per test file for this lane, from
* expected-durations.json (see scripts/update-test-durations.mjs).
* @param {string} cwd
* @returns {Record<string, number>}
*/
function loadExpectedDurations(cwd) {
const durations = {};
try {
const raw = JSON.parse(readFileSync(join(cwd, "expected-durations.json"), "utf8"));
const step = options["step"] || "";
const lane = step.includes("asan")
? "asan"
: step.includes("musl")
? "musl"
: isWindows || step.includes("windows")
? "windows"
: "default";
for (const [path, entry] of Object.entries(raw)) {
if (path === "_meta") continue;
const ms = entry[lane] ?? entry.default ?? entry.asan ?? entry.musl ?? entry.windows;
if (typeof ms === "number") durations[path] = ms;
}
} catch (e) {
console.warn("expected-durations.json not loaded:", e?.message || e);
}
return durations;
}
/**
* @param {string} cwd
* @param {string[]} testModifiers
* @param {TestExpectation[]} testExpectations
* @returns {string[]}
*/
function getRelevantTests(cwd, testModifiers, testExpectations) {
let tests = getTests(cwd);
const availableTests = [];
const filteredTests = [];
if (options["node-tests"]) {
tests = tests.filter(isNodeTest);
}
const isMatch = (testPath, filter) => {
return testPath.replace(/\\/g, "/").includes(filter);
};
const getFilter = filter => {
return (
filter
?.split(",")
.map(part => part.trim())
.filter(Boolean) ?? []
);
};
const includes = options["include"]?.flatMap(getFilter);
if (includes?.length) {
availableTests.push(...tests.filter(testPath => includes.some(filter => isMatch(testPath, filter))));
!isQuiet && console.log("Including tests:", includes, availableTests.length, "/", tests.length);
} else {
availableTests.push(...tests);
}
const excludes = options["exclude"]?.flatMap(getFilter);
if (excludes?.length) {
const excludedTests = availableTests.filter(testPath => excludes.some(filter => isMatch(testPath, filter)));
if (excludedTests.length) {
for (const testPath of excludedTests) {
const index = availableTests.indexOf(testPath);
if (index !== -1) {
availableTests.splice(index, 1);
}
}
!isQuiet && console.log("Excluding tests:", excludes, excludedTests.length, "/", availableTests.length);
}
}
// Drop the slowest files by expected duration. Used on lanes that trade a
// little coverage for throughput; the same files still run on other lanes.
const skipSlowerThan = parseInt(options["skip-slower-than"]);
if (skipSlowerThan > 0) {
const durations = loadExpectedDurations(cwd);
const slow = availableTests.filter(testPath => (durations[testPath.replaceAll("\\", "/")] ?? 0) >= skipSlowerThan);
for (const testPath of slow) availableTests.splice(availableTests.indexOf(testPath), 1);
!isQuiet &&
console.log(
`Skipping tests slower than ${skipSlowerThan}ms:`,
slow.length,
"/",
availableTests.length + slow.length,
);
}
const skipExpectations = testExpectations
.filter(
({ modifiers, expectations }) =>
!modifiers?.length || testModifiers.some(modifier => modifiers?.includes(modifier)),
)
.map(({ filename }) => filename.replace("test/", ""));
if (skipExpectations.length) {
const skippedTests = availableTests.filter(testPath => skipExpectations.some(filter => isMatch(testPath, filter)));
if (skippedTests.length) {
for (const testPath of skippedTests) {
const index = availableTests.indexOf(testPath);
if (index !== -1) {
availableTests.splice(index, 1);
}
}
!isQuiet && console.log("Skipping tests:", skipExpectations, skippedTests.length, "/", availableTests.length);
}
}
const shardId = parseInt(options["shard"]);
const maxShards = parseInt(options["max-shards"]);
if (filters?.length) {
filteredTests.push(...availableTests.filter(testPath => filters.some(filter => isMatch(testPath, filter))));
!isQuiet && console.log("Filtering tests:", filteredTests.length, "/", availableTests.length);
} else if (options["smoke"] !== undefined) {
const smokePercent = parseFloat(options["smoke"]) || 0.01;
const smokeCount = Math.ceil(availableTests.length * smokePercent);
const smokeTests = new Set();
for (let i = 0; i < smokeCount; i++) {
const randomIndex = Math.floor(Math.random() * availableTests.length);
smokeTests.add(availableTests[randomIndex]);
}
filteredTests.push(...Array.from(smokeTests));
!isQuiet && console.log("Smoking tests:", filteredTests.length, "/", availableTests.length);
} else if (maxShards > 1) {
// Longest-processing-time-first bin packing across shards using the
// checked-in median wall-clock durations (see scripts/update-test-durations.mjs).
// Every shard computes the same deterministic assignment from the same
// sorted input and picks its own bin, so the result is identical across
// machines without any coordination. Tests absent from the table (new
// files, or the file failing to load) fall back to the table's median so
// they spread across shards instead of all landing on shard 0.
const durations = loadExpectedDurations(cwd);
const known = Object.values(durations).sort((a, b) => a - b);
const unknownCost = known.length ? known[Math.floor(known.length / 2)] : 100;
const costOf = testPath => durations[testPath.replaceAll("\\", "/")] ?? unknownCost;
// Stable order for equal costs so the packing is reproducible.
const order = availableTests
.map((testPath, originalIndex) => ({ testPath, originalIndex, cost: costOf(testPath) }))
.sort((a, b) => b.cost - a.cost || a.originalIndex - b.originalIndex);
const load = new Float64Array(maxShards);
const assigned = Array.from({ length: maxShards }, () => []);
for (const { testPath, originalIndex, cost } of order) {
let bin = 0;
for (let s = 1; s < maxShards; s++) if (load[s] < load[bin]) bin = s;
load[bin] += cost;
assigned[bin].push({ testPath, originalIndex });
}
// Restore the within-shard order the rest of the pipeline expects
// (docker-last / modified-first sorts below are stable over this).
assigned[shardId].sort((a, b) => a.originalIndex - b.originalIndex);
for (const { testPath } of assigned[shardId]) filteredTests.push(testPath);
!isQuiet &&
console.log(
"Sharding tests (LPT):",
shardId,
"/",
maxShards,
"with tests",
filteredTests.length,
"/",
availableTests.length,
"est",
Math.round(load[shardId] / 1000) + "s",
"of",
Math.round(Math.max(...load) / 1000) + "s max",
);
} else {
filteredTests.push(...availableTests);
}
// Run docker-backed tests (the prefixes the coordinator prestarts) last in
// the shard: the coordinator kicks off `compose up` for their services when
// the runner starts, but a cold mysqld/postgres takes ~10s to become
// healthy. Scheduling those files after the non-docker ones lets container
// init overlap with real test work instead of being paid as wall time
// inside the first docker test's beforeAll. Stable sort: relative order is
// otherwise preserved, and sharding above is unaffected.
filteredTests.sort((a, b) => Number(needsDockerService(a)) - Number(needsDockerService(b)));
// Prioritize modified test files
if (allFiles.length > 0) {
const modifiedTests = new Set(
allFiles
.filter(filename => filename.startsWith("test/") && isTest(filename))
.map(filename => filename.slice("test/".length)),
);
if (modifiedTests.size > 0) {
return filteredTests
.map(testPath => testPath.replaceAll("\\", "/"))
.sort((a, b) => {
const aModified = modifiedTests.has(a);
const bModified = modifiedTests.has(b);
if (aModified && !bModified) return -1;
if (!aModified && bModified) return 1;
return 0;
});
}
}
return filteredTests;
}
/**
* @param {string} bunExe
* @returns {string}
*/
function getExecPath(bunExe) {
let execPath;
let error;
try {
const { error, stdout } = spawnSync(bunExe, ["--print", "process.argv[0]"], {
encoding: "utf-8",
timeout: spawnTimeout,
env: {
PATH: process.env.PATH,
BUN_DEBUG_QUIET_LOGS: 1,
},
});
if (error) {
throw error;
}
execPath = stdout.trim();
} catch (cause) {
error = cause;
}
if (execPath) {
if (isExecutable(execPath)) {
return execPath;
}
error = new Error(`File is not an executable: ${execPath}`);
}
throw new Error(`Could not find executable: ${bunExe}`, { cause: error });
}
/**
* @param {string} target
* @param {string} [buildId]
* @returns {Promise<string>}
*/
async function getExecPathFromBuildKite(target, buildId) {
if (existsSync(target) || target.includes("/")) {
return getExecPath(target);
}
const releasePath = join(cwd, "release");
mkdirSync(releasePath, { recursive: true });
let zipPath;
downloadLoop: for (let i = 0; i < 10; i++) {
// build-bun also uploads libbun-*.a / libbun_rust.a / dep libs; only the zips are wanted here.
const args = ["artifact", "download", "*.zip", releasePath, "--step", target];
if (buildId) {
args.push("--build", buildId);
}
const { error } = await spawnSafe({
command: "buildkite-agent",
args,
timeout: 120000,
});
if (error === "timeout") {
throw new Error(
`buildkite-agent artifact download timed out after 120s for step '${target}'. ` +
`Refusing to continue with a partial download (would silently fall back to the wrong binary).`,
);
}
zipPath = readdirSync(releasePath, { recursive: true, encoding: "utf-8" })
.filter(filename => /^bun.*\.zip$/i.test(filename))
.map(filename => join(releasePath, filename))
.sort((a, b) => b.includes("profile") - a.includes("profile"))
.at(0);
if (zipPath) {
break downloadLoop;
}
console.warn(`Waiting for ${target}.zip to be available...`);
await new Promise(resolve => setTimeout(resolve, i * 1000));
}
if (!zipPath) {
throw new Error(`Could not find ${target}.zip from Buildkite: ${releasePath}`);
}
await unzip(zipPath, releasePath);
const releaseFiles = readdirSync(releasePath, { recursive: true, encoding: "utf-8" });
for (const entry of releaseFiles) {
const execPath = join(releasePath, entry);
if (/bun(?:-[a-z]+)?(?:\.exe)?$/i.test(entry) && statSync(execPath).isFile()) {
return execPath;
}
}
console.warn(`Found ${releaseFiles.length} files in ${releasePath}:`, releaseFiles);
throw new Error(`Could not find executable from BuildKite: ${releasePath}`);
}
/**
* @param {string} execPath
* @returns {string}
*/
function getRevision(execPath) {
try {
const { error, stdout } = spawnSync(execPath, ["--revision"], {
encoding: "utf-8",
timeout: spawnTimeout,
env: {
PATH: process.env.PATH,
BUN_DEBUG_QUIET_LOGS: 1,
},
});
if (error) {
throw error;
}
return stdout.trim();
} catch (error) {
console.warn(error);
return "<unknown>";
}
}
/**
* @param {...string} paths
* @returns {string}
*/
function addPath(...paths) {
if (isWindows) {
return paths.join(";");
}
return paths.join(":");
}
/**
* @returns {string | undefined}
*/
function getTestLabel() {
return getBuildLabel()?.replace(" - test-bun", "");
}
/**
* @param {TestResult | TestResult[]} result
* @param {boolean} concise
* @param {number} retries
* @returns {string}
*/
function formatTestToMarkdown(result, concise, retries) {
const results = Array.isArray(result) ? result : [result];
const buildLabel = getTestLabel();
const buildUrl = getBuildUrl();
const platform = buildUrl ? `<a href="${buildUrl}">${buildLabel}</a>` : buildLabel;
let markdown = "";
for (const { testPath, ok, tests, error, stdoutPreview: stdout } of results) {
if (ok || error === "SIGTERM") {
continue;
}
let errorLine;
for (const { error } of tests) {
if (!error) {
continue;
}
const { file, line } = error;
if (line) {
errorLine = line;
break;
}
}
const testTitle = testPath.replace(/\\/g, "/");
const testUrl = getFileUrl(testPath, errorLine);
if (concise) {
markdown += "<li>";
} else {
markdown += "<details><summary>";
}
if (testUrl) {
markdown += `<a href="${testUrl}"><code>${testTitle}</code></a>`;
} else {
markdown += `<a><code>${testTitle}</code></a>`;
}
if (error) {
markdown += ` - ${error}`;
}
if (platform) {
markdown += ` on ${platform}`;
}
if (retries > 0) {
markdown += ` (${retries} ${retries === 1 ? "retry" : "retries"})`;
}
if (newFiles.includes(testTitle)) {
markdown += ` (new)`;
}
if (concise) {
markdown += "</li>\n";
} else {
markdown += "</summary>\n\n";
if (isBuildkite) {
const preview = escapeCodeBlock(stdout);
markdown += `\`\`\`terminal\n${preview}\n\`\`\`\n`;
} else {
const preview = escapeHtml(stripAnsi(stdout));
markdown += `<pre><code>${preview}</code></pre>\n`;
}
markdown += "\n\n</details>\n\n";
}
}
return markdown;
}
/**
* @param {string} glob
*/
function uploadArtifactsToBuildKite(glob) {
spawn("buildkite-agent", ["artifact", "upload", glob], {
stdio: ["ignore", "ignore", "ignore"],
timeout: spawnTimeout,
cwd,
});
}
/**
* @param {string} name
* @param {string} value
*/
function reportOutputToGitHubAction(name, value) {
const outputPath = process.env["GITHUB_OUTPUT"];
if (!outputPath) {
return;
}
const delimeter = Math.random().toString(36).substring(2, 15);
const content = `${name}<<${delimeter}\n${value}\n${delimeter}\n`;
appendFileSync(outputPath, content);
}
/**
* @param {string} color
* @returns {string}
*/
function getAnsi(color) {
switch (color) {
case "red":
return "\x1b[31m";
case "green":
return "\x1b[32m";
case "yellow":
return "\x1b[33m";
case "blue":
return "\x1b[34m";
case "reset":
return "\x1b[0m";
case "gray":
return "\x1b[90m";
default:
return "";
}
}
/**
* @param {string} string
* @returns {string}
*/
function stripAnsi(string) {
return string.replace(/\u001b\[\d+m/g, "");
}
/**
* @param {string} string
* @returns {string}
*/
function unescapeGitHubAction(string) {
return string.replace(/%25/g, "%").replace(/%0D/g, "\r").replace(/%0A/g, "\n");
}
/**
* @param {string} string
* @returns {string}
*/
function escapeHtml(string) {
return string
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/`/g, "&#96;");
}
/**
* @param {string} string
* @returns {string}
*/
function escapeCodeBlock(string) {
return string.replace(/`/g, "\\`");
}
/**
* @param {string} string
* @returns {number | undefined}
*/
function parseDuration(duration) {
const match = /(\d+\.\d+)(m?s)/.exec(duration);
if (!match) {
return undefined;
}
const [, value, unit] = match;
return parseFloat(value) * (unit === "ms" ? 1 : 1000);
}
/**
* @param {string} execPath
* @returns {boolean}
*/
function isExecutable(execPath) {
if (!existsSync(execPath) || !statSync(execPath).isFile()) {
return false;
}
try {
accessSync(execPath, fs.X_OK);
} catch {
return false;
}
return true;
}
/**
* @param {"pass" | "fail" | "cancel"} [outcome]
*/
function getExitCode(outcome) {
if (outcome === "pass") {
return 0;
}
if (!isBuildkite) {
return 1;
}
// On Buildkite, you can define a `soft_fail` property to differentiate
// from failing tests and the runner itself failing.
if (outcome === "fail") {
return 2;
}
if (outcome === "cancel") {
return 3;
}
return 1;
}
// A flaky segfault, sigtrap, or sigkill must never be ignored.
// If it happens in CI, it will happen to our users.
// Flaky AddressSanitizer errors cannot be ignored since they still represent real bugs.
function isAlwaysFailure(error) {
error = ((error || "") + "").toLowerCase().trim();
return (
error.includes("segmentation fault") ||
error.includes("illegal instruction") ||
error.includes("unchecked exception") ||
error.includes("sigtrap") ||
error.includes("sigabrt") ||
error.includes("sigkill") ||
error.includes("error: addresssanitizer") ||
error.includes("internal assertion failure") ||
error.includes("core dumped") ||
error.includes("crash reported")
);
}
/**
* @param {string} signal
*/
function onExit(signal) {
const label = `${getAnsi("red")}Received ${signal}, exiting...${getAnsi("reset")}`;
startGroup(label, () => {
markBuildkiteStepReported();
process.exit(getExitCode("cancel"));
});
}
let getBuildkiteAnalyticsToken = () => {
let token = getSecret("TEST_REPORTING_API", { required: true });
getBuildkiteAnalyticsToken = () => token;
return token;
};
/**
* Generate a JUnit XML report from test results
* @param {string} outfile - The path to write the JUnit XML report to
* @param {TestResult[]} results - The test results to include in the report
*/
function generateJUnitReport(outfile, results) {
!isQuiet && console.log(`Generating JUnit XML report: ${outfile}`);
// Start the XML document
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
// Add an overall testsuite container with metadata
const totalTests = results.length;
const totalFailures = results.filter(r => r.status === "fail").length;
const timestamp = new Date().toISOString();
// Calculate total time
const totalTime = results.reduce((sum, result) => {
const duration = result.duration || 0;
return sum + duration / 1000; // Convert ms to seconds
}, 0);
// Create a unique package name to identify this run
const packageName = `bun.internal.${process.env.BUILDKITE_PIPELINE_SLUG || "tests"}`;
xml += `<testsuites name="${escapeXml(packageName)}" tests="${totalTests}" failures="${totalFailures}" time="${totalTime.toFixed(3)}" timestamp="${timestamp}">\n`;
// Group results by test file
const testSuites = new Map();
for (const result of results) {
const { testPath, ok, status, error, tests, stdoutPreview, stdout, duration = 0 } = result;
if (!testSuites.has(testPath)) {
testSuites.set(testPath, {
name: testPath,
tests: [],
failures: 0,
errors: 0,
skipped: 0,
time: 0,
timestamp: timestamp,
hostname: getHostname(),
stdout: stdout || stdoutPreview || "",
});
}
const suite = testSuites.get(testPath);
// For test suites with granular test information
if (tests.length > 0) {
for (const test of tests) {
const { test: testName, status: testStatus, duration: testDuration = 0, errors: testErrors = [] } = test;
suite.time += testDuration / 1000; // Convert to seconds
const testCase = {
name: testName,
classname: `${packageName}.${testPath.replace(/[\/\\]/g, ".")}`,
time: testDuration / 1000, // Convert to seconds
};
if (testStatus === "fail") {
suite.failures++;
// Collect error details
let errorMessage = "Test failed";
let errorType = "AssertionError";
let errorContent = "";
if (testErrors && testErrors.length > 0) {
const primaryError = testErrors[0];
errorMessage = primaryError.name || "Test failed";
errorType = primaryError.name || "AssertionError";
errorContent = primaryError.stack || primaryError.name;
if (testErrors.length > 1) {
errorContent +=
"\n\nAdditional errors:\n" +
testErrors
.slice(1)
.map(e => e.stack || e.name)
.join("\n");
}
} else {
errorContent = error || "Unknown error";
}
testCase.failure = {
message: errorMessage,
type: errorType,
content: errorContent,
};
} else if (testStatus === "skip" || testStatus === "todo") {
suite.skipped++;
testCase.skipped = {
message: testStatus === "skip" ? "Test skipped" : "Test marked as todo",
};
}
suite.tests.push(testCase);
}
} else {
// For test suites without granular test information (e.g., bun install tests)
suite.time += duration / 1000; // Convert to seconds
const testCase = {
name: basename(testPath),
classname: `${packageName}.${testPath.replace(/[\/\\]/g, ".")}`,
time: duration / 1000, // Convert to seconds
};
if (status === "fail") {
suite.failures++;
testCase.failure = {
message: "Test failed",
type: "AssertionError",
content: error || "Unknown error",
};
}
suite.tests.push(testCase);
}
}
// Write each test suite to the XML
for (const [name, suite] of testSuites) {
xml += ` <testsuite name="${escapeXml(name)}" tests="${suite.tests.length}" failures="${suite.failures}" errors="${suite.errors}" skipped="${suite.skipped}" time="${suite.time.toFixed(3)}" timestamp="${suite.timestamp}" hostname="${escapeXml(suite.hostname)}">\n`;
// Include system-out if we have stdout
if (suite.stdout) {
xml += ` <system-out><![CDATA[${suite.stdout}]]></system-out>\n`;
}
// Write each test case
for (const test of suite.tests) {
xml += ` <testcase name="${escapeXml(test.name)}" classname="${escapeXml(test.classname)}" time="${test.time.toFixed(3)}"`;
if (test.skipped) {
xml += `>\n <skipped message="${escapeXml(test.skipped.message)}"/>\n </testcase>\n`;
} else if (test.failure) {
xml += `>\n`;
xml += ` <failure message="${escapeXml(test.failure.message)}" type="${escapeXml(test.failure.type)}"><![CDATA[${test.failure.content}]]></failure>\n`;
xml += ` </testcase>\n`;
} else {
xml += `/>\n`;
}
}
xml += ` </testsuite>\n`;
}
xml += `</testsuites>`;
// Create directory if it doesn't exist
const dir = dirname(outfile);
mkdirSync(dir, { recursive: true });
// Write to file
writeFileSync(outfile, xml);
!isQuiet && console.log(`JUnit XML report written to ${outfile}`);
}
let isUploadingToBuildKite = false;
const junitUploadQueue = [];
async function addToJunitUploadQueue(junitFilePath) {
junitUploadQueue.push(junitFilePath);
if (!isUploadingToBuildKite) {
drainJunitUploadQueue();
}
}
async function drainJunitUploadQueue() {
isUploadingToBuildKite = true;
while (junitUploadQueue.length > 0) {
const testPath = junitUploadQueue.shift();
await uploadJUnitToBuildKite(testPath)
.then(uploadSuccess => {
unlink(testPath, () => {
if (!uploadSuccess) {
console.error(`Failed to upload JUnit report for ${testPath}`);
}
});
})
.catch(err => {
console.error(`Error uploading JUnit report for ${testPath}:`, err);
});
}
isUploadingToBuildKite = false;
}
/**
* Upload JUnit XML report to BuildKite Test Analytics
* @param {string} junitFile - Path to the JUnit XML file to upload
* @returns {Promise<boolean>} - Whether the upload was successful
*/
async function uploadJUnitToBuildKite(junitFile) {
const fileName = basename(junitFile);
!isQuiet && console.log(`Uploading JUnit file "${fileName}" to BuildKite Test Analytics...`);
// Get BuildKite environment variables for run_env fields
const buildId = getEnv("BUILDKITE_BUILD_ID", false);
const buildUrl = getEnv("BUILDKITE_BUILD_URL", false);
const branch = getBranch();
const commit = getCommit();
const buildNumber = getEnv("BUILDKITE_BUILD_NUMBER", false);
const jobId = getEnv("BUILDKITE_JOB_ID", false);
const message = getEnv("BUILDKITE_MESSAGE", false);
try {
// Add a unique test suite identifier to help with correlation in BuildKite
const testId = fileName.replace(/\.xml$/, "");
// Use fetch and FormData instead of curl
const formData = new FormData();
// Add the JUnit file data
formData.append("data", new Blob([await readFile(junitFile)]), fileName);
formData.append("format", "junit");
formData.append("run_env[CI]", "buildkite");
// Add additional fields
if (buildId) formData.append("run_env[key]", buildId);
if (buildUrl) formData.append("run_env[url]", buildUrl);
if (branch) formData.append("run_env[branch]", branch);
if (commit) formData.append("run_env[commit_sha]", commit);
if (buildNumber) formData.append("run_env[number]", buildNumber);
if (jobId) formData.append("run_env[job_id]", jobId);
if (message) formData.append("run_env[message]", message);
// Add custom tags
formData.append("tags[runtime]", "bun");
formData.append("tags[suite]", testId);
// Add additional context information specific to this run
formData.append("run_env[source]", "junit-import");
formData.append("run_env[collector]", "bun-runner");
const url = "https://analytics-api.buildkite.com/v1/uploads";
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Token token="${getBuildkiteAnalyticsToken()}"`,
},
body: formData,
});
if (response.ok) {
!isQuiet && console.log(`JUnit file "${fileName}" successfully uploaded to BuildKite Test Analytics`);
try {
// Consume the body to ensure Node releases the memory.
await response.arrayBuffer();
} catch (error) {
// Don't care if this fails.
}
return true;
} else {
const errorText = await response.text();
console.error(`Failed to upload JUnit file "${fileName}": HTTP ${response.status}`, errorText);
return false;
}
} catch (error) {
console.error(`Error uploading JUnit file "${fileName}":`, error);
return false;
}
}
/**
* Escape XML special characters
* @param {string} str - String to escape
* @returns {string} - Escaped string
*/
function escapeXml(str) {
if (typeof str !== "string") return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export async function main() {
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(signal, () => onExit(signal));
}
if (!isQuiet) {
printEnvironment();
}
// FIXME: Some DNS tests hang unless we set the DNS server to 8.8.8.8
// It also appears to hang on 1.1.1.1, which could explain this issue:
// https://github.com/oven-sh/bun/issues/11136
if (isWindows && isCI) {
await spawn("pwsh", [
"-Command",
"Set-DnsClientServerAddress -InterfaceAlias 'Ethernet 4' -ServerAddresses ('8.8.8.8','8.8.4.4')",
]);
}
let doRunTests = true;
if (isCI) {
// allFiles can be empty if the GitHub API call failed (bad token, rate
// limit, non-PR build). [].every() is vacuously true, which would skip the
// entire suite and exit 0 — so require at least one file before treating
// the change set as docs-only.
if (allFiles.length > 0 && allFiles.every(filename => filename.startsWith("docs/"))) {
doRunTests = false;
}
}
let ok = true;
if (doRunTests) {
const results = await runTests();
ok = results.every(({ ok }) => ok);
}
let waitForUser = false;
while (isCI) {
const userCount = getLoggedInUserCountOrDetails();
if (!userCount) {
if (waitForUser) {
!isQuiet && console.log("No users logged in, exiting runner...");
}
break;
}
if (!waitForUser) {
startGroup("Summary");
if (typeof userCount === "number") {
console.warn(`Found ${userCount} users logged in, keeping the runner alive until logout...`);
} else {
console.warn(userCount);
}
waitForUser = true;
}
await new Promise(resolve => setTimeout(resolve, 60_000));
}
markBuildkiteStepReported();
process.exit(getExitCode(ok ? "pass" : "fail"));
}
await main();