Files
bun.sh/scripts/runner.node.mjs
2025-05-05 18:02:30 -07:00

2222 lines
61 KiB
JavaScript
Executable File

#! /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,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
statSync,
unlink,
unlinkSync,
writeFileSync,
linkSync,
symlinkSync,
} from "node:fs";
import { readFile } from "node:fs/promises";
import { userInfo } from "node:os";
import { basename, dirname, join, relative, sep, extname } from "node:path";
import { parseArgs } from "node:util";
import {
getAbi,
getAbiVersion,
getArch,
getBranch,
getBuildLabel,
getBuildUrl,
getCommit,
getDistro,
getDistroVersion,
getEnv,
getFileUrl,
getHostname,
getLoggedInUserCountOrDetails,
getOs,
getSecret,
getShell,
getWindowsExitReason,
isBuildkite,
isCI,
isGithubAction,
isMacOS,
isWindows,
isX64,
printEnvironment,
reportAnnotationToBuildKite,
startGroup,
tmpdir,
unzip,
} from "./utils.mjs";
let isQuiet = false;
const cwd = import.meta.dirname ? dirname(import.meta.dirname) : process.cwd();
const testsPath = join(cwd, "test");
const spawnTimeout = 5_000;
const testTimeout = 3 * 60_000;
const integrationTimeout = 5 * 60_000;
const napiTimeout = 10 * 60_000;
function getNodeParallelTestTimeout(testPath) {
if (testPath.includes("test-dns")) {
return 90_000;
}
return 10_000;
}
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,
},
["quiet"]: {
type: "boolean",
default: false,
},
["smoke"]: {
type: "string",
default: undefined,
},
["vendor"]: {
type: "string",
default: undefined,
},
["retries"]: {
type: "string",
default: isCI ? "4" : "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,
},
},
});
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;
}
/**
* @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;
}
/**
* @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());
}
/**
* @returns {Promise<TestResult[]>}
*/
async function runTests() {
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);
/** @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 failedResults = [];
const maxAttempts = 1 + (parseInt(options["retries"]) || 0);
/**
* @param {string} title
* @param {function} fn
* @returns {Promise<TestResult>}
*/
const runTest = async (title, fn) => {
const index = ++i;
let result, failure, flaky;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (attempt > 1) {
await new Promise(resolve => setTimeout(resolve, 5000 + Math.random() * 10_000));
}
result = await startGroup(
attempt === 1
? `${getAnsi("gray")}[${index}/${total}]${getAnsi("reset")} ${title}`
: `${getAnsi("gray")}[${index}/${total}]${getAnsi("reset")} ${title} ${getAnsi("gray")}[attempt #${attempt}]${getAnsi("reset")}`,
fn,
);
const { ok, stdoutPreview, error } = result;
if (ok) {
if (failure) {
flakyResults.push(failure);
} else {
okResults.push(result);
}
break;
}
const color = attempt >= maxAttempts ? "red" : "yellow";
const label = `${getAnsi(color)}[${index}/${total}] ${title} - ${error}${getAnsi("reset")}`;
startGroup(label, () => {
process.stderr.write(stdoutPreview);
});
failure ||= result;
flaky ||= true;
if (attempt >= maxAttempts || isAlwaysFailure(error)) {
flaky = false;
failedResults.push(failure);
}
}
if (!failure) {
return result;
}
if (isBuildkite) {
// Group flaky tests together, regardless of the title
const context = flaky ? "flaky" : title;
const style = flaky || title.startsWith("vendor") ? "warning" : "error";
if (title.startsWith("vendor")) {
const content = formatTestToMarkdown({ ...failure, testPath: title });
if (content) {
reportAnnotationToBuildKite({ context, label: title, content, style });
}
} else {
const content = formatTestToMarkdown(failure);
if (content) {
reportAnnotationToBuildKite({ context, label: title, content, style });
}
}
}
if (isGithubAction) {
const summaryPath = process.env["GITHUB_STEP_SUMMARY"];
if (summaryPath) {
const longMarkdown = formatTestToMarkdown(failure);
appendFileSync(summaryPath, longMarkdown);
}
const shortMarkdown = formatTestToMarkdown(failure, true);
appendFileSync("comment.md", shortMarkdown);
}
if (options["bail"]) {
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 }));
}
}
if (!failedResults.length) {
for (const testPath of tests) {
const absoluteTestPath = join(testsPath, testPath);
const title = relative(cwd, absoluteTestPath).replaceAll(sep, "/");
if (isNodeTest(testPath)) {
const testContent = readFileSync(absoluteTestPath, "utf-8");
const runWithBunTest =
title.includes("needs-test") || testContent.includes("bun:test") || testContent.includes("node:test");
const subcommand = runWithBunTest ? "test" : "run";
await runTest(title, async () => {
const { ok, error, stdout } = await spawnBun(execPath, {
cwd: cwd,
args: [subcommand, "--config=" + join(import.meta.dirname, "../bunfig.node-test.toml"), absoluteTestPath],
timeout: getNodeParallelTestTimeout(title),
env: {
FORCE_COLOR: "0",
NO_COLOR: "1",
BUN_DEBUG_QUIET_LOGS: "1",
},
stdout: chunk => pipeTestStdout(process.stdout, chunk),
stderr: chunk => pipeTestStdout(process.stderr, chunk),
});
const mb = 1024 ** 3;
const stdoutPreview = stdout.slice(0, mb).split("\n").slice(0, 50).join("\n");
return {
testPath: title,
ok: ok,
status: ok ? "pass" : "fail",
error: error,
errors: [],
tests: [],
stdout: stdout,
stdoutPreview: stdoutPreview,
};
});
} else {
await runTest(title, async () => spawnBunTest(execPath, join("test", testPath)));
}
}
}
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}`);
}
for (const testPath of testPaths) {
const title = join(relative(cwd, vendorPath), testPath).replace(/\\/g, "/");
if (testRunner === "bun") {
await runTest(title, () => spawnBunTest(execPath, testPath, { cwd: vendorPath }));
} 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],
}),
);
}
}
}
}
if (isGithubAction) {
reportOutputToGitHubAction("failing_tests_count", failedResults.length);
const markdown = formatTestToMarkdown(failedResults);
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 (!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 failedResults) {
console.log(`${getAnsi("red")}- ${testPath}${getAnsi("reset")}`);
}
}
if (flakyResults.length) {
console.log(`${getAnsi("yellow")}Flaky Tests:${getAnsi("reset")}`);
for (const { testPath } of flakyResults) {
console.log(`${getAnsi("yellow")}- ${testPath}${getAnsi("reset")}`);
}
}
}
// 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
*/
/**
* @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 duration;
let subprocess;
let timer;
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);
}
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 {
subprocess = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
timeout,
cwd,
env,
});
subprocess.on("spawn", () => {
timestamp = Date.now();
timer = setTimeout(() => done(resolve), timeout);
});
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 => {
const text = chunk.toString("utf-8");
stdout?.(text);
buffer += text;
});
subprocess.stderr.on("data", chunk => {
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 = /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))
) {
const [, message] = error || [];
error = message ? message.split("\n")[0].toLowerCase() : "crash";
error = error.indexOf("\\n") !== -1 ? error.substring(0, error.indexOf("\\n")) : 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";
}
} else if (exitCode === undefined) {
error = "timeout";
} else if (exitCode !== 0) {
if (isWindows) {
const winCode = getWindowsExitReason(exitCode);
if (winCode) {
exitCode = winCode;
}
}
error = `code ${exitCode}`;
}
return {
ok: exitCode === 0 && !signalCode && !spawnError,
error,
exitCode,
signalCode,
spawnError,
stdout: buffer,
timestamp: timestamp || Date.now(),
duration: duration || 0,
};
}
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;
}
/**
* @param {string} execPath Path to bun binary
* @param {SpawnOptions} options
* @returns {Promise<SpawnResult>}
*/
async function spawnBun(execPath, { args, cwd, timeout, 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,
USER: username,
HOME: homedir,
SHELL: shellPath,
FORCE_COLOR: "1",
BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1",
BUN_DEBUG_QUIET_LOGS: "1",
BUN_GARBAGE_COLLECTOR_LEVEL: "1",
BUN_JSC_randomIntegrityAuditRate: "1.0",
BUN_ENABLE_CRASH_REPORTING: "0", // change this to '1' if https://github.com/oven-sh/bun/issues/13012 is implemented
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.
};
if (basename(execPath).includes("asan")) {
bunEnv.ASAN_OPTIONS = "allow_user_segv_handler=1";
}
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;
}
try {
return await spawnSafe({
command: execPath,
args,
cwd,
timeout,
env: bunEnv,
stdout,
stderr,
});
} 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} [options]
* @param {string} [options.cwd]
* @param {string[]} [options.args]
* @returns {Promise<TestResult>}
*/
async function spawnBunTest(execPath, testPath, options = { cwd }) {
const timeout = getTestTimeout(testPath);
const perTestTimeout = Math.ceil(timeout / 2);
const absPath = join(options["cwd"], testPath);
const isReallyTest = isTestStrict(testPath) || absPath.includes("vendor");
const args = options["args"] ?? [];
const testArgs = ["test", ...args, `--timeout=${perTestTimeout}`];
// 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 { ok, error, stdout } = await spawnBun(execPath, {
args: isReallyTest ? testArgs : [...args, absPath],
cwd: options["cwd"],
timeout: isReallyTest ? timeout : 30_000,
env: {
GITHUB_ACTIONS: "true", // always true so annotations are parsed
},
stdout: chunk => pipeTestStdout(process.stdout, chunk),
stderr: chunk => pipeTestStdout(process.stderr, chunk),
});
const { tests, errors, stdout: stdoutPreview } = parseTestStdout(stdout, testPath);
// 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|v8/i.test(testPath)) {
return integrationTimeout;
}
if (/napi/i.test(testPath)) {
return napiTimeout;
}
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) {
const { ok, error, stdout, duration } = await spawnBun(execPath, {
args: ["install"],
timeout: testTimeout,
...options,
});
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
// TODO: Unclear why we decided to do this?
if (isCI && isMacOS && isX64) {
return false;
}
const unixPath = path.replaceAll(sep, "/");
return unixPath.includes("js/node/test/parallel/") || unixPath.includes("js/node/test/sequential/");
}
/**
* @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)) {
await spawnSafe({
command: "git",
args: ["clone", "--depth", "1", "--single-branch", repository, vendorPath],
timeout: testTimeout,
cwd,
});
}
await spawnSafe({
command: "git",
args: ["fetch", "--depth", "1", "origin", "tag", tag],
timeout: testTimeout,
cwd: vendorPath,
});
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,
};
},
),
);
}
/**
* @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);
}
}
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) {
for (let i = 0; i < availableTests.length; i++) {
if (i % maxShards === shardId) {
filteredTests.push(availableTests[i]);
}
}
!isQuiet &&
console.log(
"Sharding tests:",
shardId,
"/",
maxShards,
"with tests",
filteredTests.length,
"/",
availableTests.length,
);
} else {
filteredTests.push(...availableTests);
}
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++) {
const args = ["artifact", "download", "**", releasePath, "--step", target];
if (buildId) {
args.push("--build", buildId);
}
await spawnSafe({
command: "buildkite-agent",
args,
timeout: 60000,
});
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
* @returns {string}
*/
function formatTestToMarkdown(result, concise) {
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 (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} [glob]
* @param {string} [step]
*/
function listArtifactsFromBuildKite(glob, step) {
const args = [
"artifact",
"search",
"--no-color",
"--allow-empty-results",
"--include-retried-jobs",
"--format",
"%p\n",
glob || "*",
];
if (step) {
args.push("--step", step);
}
const { error, status, signal, stdout, stderr } = spawnSync("buildkite-agent", args, {
stdio: ["ignore", "ignore", "ignore"],
encoding: "utf-8",
timeout: spawnTimeout,
cwd,
});
if (status === 0) {
return stdout?.split("\n").map(line => line.trim()) || [];
}
const cause = error ?? signal ?? `code ${status}`;
console.warn("Failed to list artifacts from BuildKite:", cause, stderr);
return [];
}
/**
* @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 escapeGitHubAction(string) {
return string.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
}
/**
* @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 sigill must never be ignored.
// If it happens in CI, it will happen to our users.
function isAlwaysFailure(error) {
error = ((error || "") + "").toLowerCase().trim();
return error.includes("segmentation fault") || error.includes("sigill") || error.includes("sigtrap");
}
/**
* @param {string} signal
*/
function onExit(signal) {
const label = `${getAnsi("red")}Received ${signal}, exiting...${getAnsi("reset")}`;
startGroup(label, () => {
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')",
]);
}
const results = await runTests();
const 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));
}
process.exit(getExitCode(ok ? "pass" : "fail"));
}
await main();